Files
DarkRoom/core/dr-pipeline/ops/contrast.yaml
T
dtourolleandClaude Opus 5 65e6a96a65 Say what the application is doing, in one bar and one list
Every background job reported into a window property of its own —
library-thumbs-done, library-pin-total, library-syncing — which only the
grid ever read. A pin download that outlived the view it was started from
drew nothing at all once the user opened an image, and there was no answer
anywhere to "what is this busy with", because the answer was spread across
eight properties nothing collected.

They report to one register now (ui/dr-ui/src/activity.rs). It publishes an
aggregate, which draws a three-pixel bar across the top of the shell in
every view, and a row per job, which the settings page lists: scans,
thumbnail batches, pin and open downloads, sidecar uploads, the sync and
the trash. Failures stay on the list until they are cleared; routine
successes do not, or a scroll would bury them.

The handle removes a still-running job when it drops, so a worker that dies
mid-transfer takes its row with it rather than leaving the bar sweeping for
the rest of the session.

Also carries in-flight work from a parallel session — the drawn icon set
and the dr-pipeline ops split. dr-pipeline's build script does not compile
at this commit; ui/dr-ui does, with clippy clean and its tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 18:32:09 +02:00

111 lines
4.0 KiB
YAML

id: contrast
label: op.contrast
order: 30
doc: |
Contrast — an S-curve about a fixed mid-point.
Pushes tones away from middle grey (positive) or toward it (negative),
pivoting where the eye reads "neither light nor dark". In linear light
that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a
stop and a half above middle grey, and pivoting there would darken almost
every photograph.
The curve is applied in a perceptual domain rather than directly to linear
values. Applied linearly, an S-curve crushes shadows far harder than it
lifts highlights, because linear light devotes most of its range to the
brightest stop.
placement: |
After the capture corrections, before the region controls: it is the broad
tonal statement those controls then refine.
params:
contrast:
label: param.contrast
kind: amount
uniforms:
amount:
value: contrast / 100
doc: The shader's curve expects -1..1; the descriptor speaks -100..100.
helpers: [luminance, apply_tone_gain]
define:
contrast_curve: |
// A symmetric S-curve on a 0..1 perceptual position.
//
// `amount` above zero steepens, below zero flattens. The smoothstep form is
// used for the steepening direction because it has zero gradient at both
// ends, so the curve cannot invert however hard it is pushed — the failure
// that makes naive gain-about-a-pivot unusable past moderate settings.
fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) {
// Blend toward a smoothstep, which is the S.
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
// Flattening: pull toward the mid-point. At amount = -1 every tone
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
return mix(clamped, 0.5, -amount);
}
wgsl: |
let luma = luminance(c);
if (luma > 0.0001) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
// curve operates on luma/(2*0.18) so that middle grey lands at the
// curve's own 0.5 pivot.
let pos = clamp(luma / 0.36, 0.0, 1.0);
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
c = max(c, vec3<f32>(0.0));
tests:
- name: neutral_does_nothing
expect: { amount: 0.0 }
expect_active: false
- name: the_amount_is_normalised_to_unit_range
set: { contrast: 100 }
expect: { amount: 1.0 }
- name: the_negative_direction_normalises_too
set: { contrast: -100 }
expect: { amount: -1.0 }
- name: contrast_works_on_luminance_not_per_channel
why: |
Curving each channel separately shifts hue; the ratio form is what keeps
a blue sky blue as contrast rises.
expect_wgsl: ["luminance(c)", "apply_tone_gain"]
- name: the_pivot_is_middle_grey_not_half
why: |
Pivoting at 0.5 in linear light would darken nearly every image:
scene-referred 0.5 is well above what the eye calls mid-tone.
expect_wgsl: ["0.36"]
- name: a_division_by_luminance_is_guarded
why: |
A black pixel has zero luminance; dividing by it would produce NaN and
propagate through everything downstream.
expect_wgsl: ["luma > 0.0001"]
- name: the_curve_cannot_invert
why: |
A gain-about-a-pivot form produces a non-monotonic curve past moderate
settings, which inverts tones. smoothstep cannot.
expect_helper_wgsl: { contrast_curve: ["3.0 - 2.0 * clamped"] }