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>
This commit is contained in:
@@ -11,3 +11,15 @@ license.workspace = true
|
||||
[dependencies]
|
||||
dr-types.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
# Nodes are declared in `ops/*.yaml` and compiled to Rust by `build.rs`
|
||||
# (ARCH §5.7). The same reasoning as `ui/dr-ui`'s style.yaml: the declaration
|
||||
# is the source of truth, the Rust is generated into OUT_DIR where it cannot
|
||||
# be edited or committed by accident.
|
||||
#
|
||||
# serde_norway rather than serde_yaml for the reason recorded in the workspace
|
||||
# manifest — it is the fork still receiving releases, and it preserves mapping
|
||||
# order, which is what lets a node's parameters reach the panel in the order
|
||||
# its author wrote them.
|
||||
[build-dependencies]
|
||||
serde_norway.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
# WGSL helper functions shared between nodes.
|
||||
#
|
||||
# Files here beginning with `_` are not nodes; this one declares the helper
|
||||
# library every node may draw on by name. `build.rs` generates
|
||||
# `ops::helpers::<NAME>` from each entry, and validates that a node's
|
||||
# `helpers:` list names something defined here — a typo is a build error
|
||||
# naming the key, not a WGSL compile failure in generated source.
|
||||
#
|
||||
# **Single source of truth.** The composer deduplicates helpers by *name*, so
|
||||
# two definitions of one name would silently emit whichever came first and two
|
||||
# nodes would compute, say, luminance differently depending on graph order.
|
||||
# That is a genuinely hard bug to see, which is why a helper is defined
|
||||
# exactly once, here, and referenced everywhere else.
|
||||
|
||||
doc: |
|
||||
WGSL helper functions shared between operations.
|
||||
|
||||
Generated from `ops/_helpers.yaml`. A node names the helpers it needs and
|
||||
the composer emits each one once, however many nodes asked for it.
|
||||
|
||||
helpers:
|
||||
luminance:
|
||||
doc: |
|
||||
Rec. 709 luminance, the weighting that matches sRGB primaries.
|
||||
|
||||
Applied to camera-space values it is an approximation — the true weights
|
||||
depend on the camera matrix — but using it here keeps the tonal operations
|
||||
working on sensor-native data, where highlight headroom still exists.
|
||||
wgsl: |
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
tone_position:
|
||||
doc: |
|
||||
Map linear luminance onto a perceptual 0..1 position.
|
||||
|
||||
Tonal controls must feel evenly spaced to the eye, and linear light is
|
||||
not: middle grey sits at 0.18, so a linear weight would call almost
|
||||
everything a shadow. The cube root approximates lightness cheaply and
|
||||
behaves well near zero, where a log would diverge.
|
||||
wgsl: |
|
||||
fn tone_position(luma: f32) -> f32 {
|
||||
return clamp(pow(max(luma, 0.0), 1.0 / 3.0), 0.0, 1.0);
|
||||
}
|
||||
|
||||
apply_tone_gain:
|
||||
doc: |
|
||||
Scale a colour by a gain while preserving its hue.
|
||||
|
||||
Multiplying the three channels equally keeps chromaticity fixed, so
|
||||
lifting shadows does not desaturate them the way an additive lift would.
|
||||
wgsl: |
|
||||
fn apply_tone_gain(c: vec3<f32>, gain: f32) -> vec3<f32> {
|
||||
return c * gain;
|
||||
}
|
||||
|
||||
colour_saturation:
|
||||
doc: |
|
||||
How far a colour sits from grey, in 0..1.
|
||||
|
||||
The max-minus-min definition (HSV chroma) rather than a standard
|
||||
deviation: it matches what the eye reads as 'colourfulness' and it is what
|
||||
makes vibrance's roll-off land where users expect.
|
||||
wgsl: |
|
||||
fn colour_saturation(c: vec3<f32>) -> f32 {
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
if (hi <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return (hi - lo) / hi;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
id: blacks_whites
|
||||
label: op.blacks_whites
|
||||
order: 50
|
||||
|
||||
doc: |
|
||||
Blacks and whites — the endpoints, where the image clips.
|
||||
|
||||
Narrow where [`highlights_shadows`](highlights_shadows.yaml) is broad:
|
||||
whites act only in the top quarter, blacks only in the bottom quarter. That
|
||||
difference in reach is the whole distinction between the two pairs.
|
||||
|
||||
placement: |
|
||||
After the broad recovery controls: the endpoints are placed once the tonal
|
||||
range they bound has been settled.
|
||||
|
||||
params:
|
||||
blacks:
|
||||
label: param.blacks
|
||||
kind: amount
|
||||
whites:
|
||||
label: param.whites
|
||||
kind: amount
|
||||
|
||||
uniforms:
|
||||
white_amount: whites / 100
|
||||
black_amount:
|
||||
value: blacks / 100 * 0.02
|
||||
doc: |
|
||||
A small linear offset. Scene-referred black sits near zero, so the
|
||||
useful range here is far smaller than a stop — 0.02 is already a
|
||||
visible lift on a dark frame.
|
||||
|
||||
helpers: [luminance, tone_position, apply_tone_gain]
|
||||
|
||||
wgsl: |
|
||||
let luma = luminance(c);
|
||||
let pos = tone_position(luma);
|
||||
|
||||
// Narrow weights concentrated at each end — this is what separates these
|
||||
// controls from highlights/shadows, which are broad. Whites act only in the
|
||||
// top quarter, blacks only in the bottom quarter.
|
||||
let white_w = smoothstep(0.75, 1.0, pos);
|
||||
let black_w = 1.0 - smoothstep(0.0, 0.25, pos);
|
||||
|
||||
// Whites scale the top end multiplicatively, moving the clipping point.
|
||||
let white_gain = exp2(white_amount * white_w);
|
||||
c = apply_tone_gain(c, white_gain);
|
||||
|
||||
// Blacks shift the floor. This one is deliberately *additive*: the point of
|
||||
// a blacks control is to set where the image reaches zero, and a multiply
|
||||
// can never bring a non-zero value to zero nor lift a true black off it.
|
||||
c = c + vec3<f32>(black_amount * black_w);
|
||||
|
||||
// The subtractive direction can push below zero, which is not light.
|
||||
c = max(c, vec3<f32>(0.0));
|
||||
|
||||
tests:
|
||||
- name: it_starts_neutral
|
||||
expect_active: false
|
||||
|
||||
- name: one_parameter_is_enough_to_activate
|
||||
set: { whites: 20 }
|
||||
expect_active: true
|
||||
|
||||
- name: the_blacks_offset_stays_small
|
||||
why: |
|
||||
Scene-referred black is near zero; a full-stop control here would be
|
||||
unusable, moving the image to grey at a fraction of its travel.
|
||||
set: { blacks: 100 }
|
||||
expect_range: { black_amount: [0.0, 0.05] }
|
||||
|
||||
- name: blacks_are_additive_not_multiplicative
|
||||
why: |
|
||||
A multiply can never bring a non-zero value to zero nor lift a true
|
||||
black off it, which is precisely what this control is for.
|
||||
expect_wgsl: ["c = c + vec3<f32>(black_amount * black_w);"]
|
||||
|
||||
- name: the_result_cannot_go_below_zero
|
||||
why: A negative radiance is not light, and it poisons everything downstream.
|
||||
expect_wgsl: ["c = max(c, vec3<f32>(0.0));"]
|
||||
|
||||
- name: these_endpoints_are_narrower_than_the_recovery_controls
|
||||
why: |
|
||||
The one property distinguishing this node from highlights_shadows. If
|
||||
these weights widened to match, the two would be the same control twice.
|
||||
expect_wgsl: ["smoothstep(0.75, 1.0, pos)", "smoothstep(0.0, 0.25, pos)"]
|
||||
@@ -0,0 +1,68 @@
|
||||
id: brilliance
|
||||
label: op.brilliance
|
||||
order: 70
|
||||
|
||||
doc: |
|
||||
Brilliance — shadows up and highlights down at once.
|
||||
|
||||
Apple's control, and a genuinely different idea from contrast: it applies
|
||||
the opposite correction at each end of the range while leaving mid-tones
|
||||
alone. The result reads as "more light in the scene" rather than "less
|
||||
contrast", because local relationships survive where a plain contrast
|
||||
reduction flattens them.
|
||||
|
||||
It overlaps with highlights/shadows deliberately — one control doing both
|
||||
in a fixed relationship is easier to reach for than two controls needing
|
||||
to be balanced against each other.
|
||||
|
||||
placement: |
|
||||
After the tone curve has had the final word on tone, and before the colour
|
||||
controls, which should act on the tones the photographer has settled.
|
||||
|
||||
params:
|
||||
brilliance:
|
||||
label: param.brilliance
|
||||
kind: amount
|
||||
|
||||
uniforms:
|
||||
amount:
|
||||
value: brilliance / 100 * 0.5
|
||||
doc: |
|
||||
Half a stop at each end at full travel — the two ends move apart by a
|
||||
stop in total, which is a strong but not destructive flattening.
|
||||
|
||||
helpers: [luminance, tone_position, apply_tone_gain]
|
||||
|
||||
wgsl: |
|
||||
let luma = luminance(c);
|
||||
let pos = tone_position(luma);
|
||||
|
||||
// Opposite corrections at the two ends: shadows up, highlights down, both
|
||||
// tapering to nothing at the mid-point. This is what separates brilliance
|
||||
// from a contrast control — mid-tones keep their local relationships, so
|
||||
// the image gains apparent light rather than losing structure.
|
||||
let lift = (1.0 - smoothstep(0.0, 0.5, pos)) * amount;
|
||||
let pull = smoothstep(0.5, 1.0, pos) * amount;
|
||||
|
||||
// A mild saturation compensation. Flattening the tonal range washes colour
|
||||
// out; without this, brilliance looks faded at useful settings.
|
||||
let gain = exp2(lift - pull);
|
||||
c = c * gain;
|
||||
|
||||
let luma_after = luminance(c);
|
||||
c = mix(vec3<f32>(luma_after), c, 1.0 + max(amount, 0.0) * 0.15);
|
||||
c = max(c, vec3<f32>(0.0));
|
||||
|
||||
tests:
|
||||
- name: it_starts_neutral
|
||||
expect_active: false
|
||||
|
||||
- name: full_travel_is_half_a_stop_at_each_end
|
||||
set: { brilliance: 100 }
|
||||
expect: { amount: 0.5 }
|
||||
|
||||
- name: the_two_ends_move_in_opposite_directions
|
||||
why: |
|
||||
The property that makes this brilliance rather than a contrast slider.
|
||||
Both terms scaling the same way would flatten without lifting.
|
||||
expect_wgsl: ["let gain = exp2(lift - pull);"]
|
||||
@@ -0,0 +1,14 @@
|
||||
id: colour_mixer
|
||||
order: 100
|
||||
rust: ColourMixer
|
||||
|
||||
why_rust: |
|
||||
Thirty-six parameters — twelve hue bands times hue, saturation and
|
||||
luminance — generated as a grid, each carrying a `Facet` naming its band and
|
||||
the band's centre on the hue wheel. Spelling that out as thirty-six YAML
|
||||
entries would be a worse description than the loop that produces it, and the
|
||||
band centres are computed rather than listed.
|
||||
|
||||
placement: |
|
||||
Last. It is the finishing control, and it should act on the tones the user
|
||||
has already settled.
|
||||
@@ -0,0 +1,110 @@
|
||||
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"] }
|
||||
@@ -0,0 +1,57 @@
|
||||
# TRACES: FR-DEV-3a | FR-DEV-3c
|
||||
id: exposure
|
||||
label: op.exposure
|
||||
order: 20
|
||||
|
||||
doc: |
|
||||
Exposure — a linear gain, expressed in stops.
|
||||
|
||||
The simplest operation in the pipeline and the one that most justifies
|
||||
working in linear light: a stop is a doubling, so exposure is a single
|
||||
multiply. Applied to gamma-encoded data it would be neither a doubling nor
|
||||
reversible, which is why this stage sits where it does (ARCH §5.2).
|
||||
|
||||
placement: |
|
||||
A correction to how the scene was captured, so it precedes every operation
|
||||
that interprets tone.
|
||||
|
||||
params:
|
||||
exposure:
|
||||
label: param.exposure
|
||||
kind: stops
|
||||
min: -5
|
||||
max: 5
|
||||
doc: |
|
||||
±5 stops. Wider than most edits need, but recovering a badly
|
||||
underexposed frame is a real use and raw data often supports it.
|
||||
|
||||
uniforms:
|
||||
gain:
|
||||
value: exp2(exposure)
|
||||
doc: The linear gain for the current setting.
|
||||
|
||||
wgsl: |
|
||||
c = c * gain;
|
||||
|
||||
tests:
|
||||
- name: one_stop_is_a_doubling
|
||||
why: |
|
||||
The definition of a stop. If this is wrong, every exposure adjustment is
|
||||
subtly off and no test of "looks right" would catch it.
|
||||
set: { exposure: 1 }
|
||||
expect: { gain: 2.0 }
|
||||
|
||||
- name: one_stop_down_is_a_halving
|
||||
set: { exposure: -1 }
|
||||
expect: { gain: 0.5 }
|
||||
|
||||
- name: stops_compose_additively
|
||||
why: +2 stops must equal +1 applied twice.
|
||||
set: { exposure: 2 }
|
||||
expect: { gain: 4.0 }
|
||||
|
||||
- name: neutral_is_unity_gain
|
||||
why: |
|
||||
An unedited image must be the image, not an interpretation of it.
|
||||
expect: { gain: 1.0 }
|
||||
expect_active: false
|
||||
@@ -0,0 +1,81 @@
|
||||
id: highlights_shadows
|
||||
label: op.highlights_shadows
|
||||
order: 40
|
||||
|
||||
doc: |
|
||||
Highlights and shadows — broad, overlapping recovery at both ends.
|
||||
|
||||
Weights are built from smoothstep rather than a hard threshold: a sharp
|
||||
boundary produces visible banding on a gradient — a sky is the worst case,
|
||||
and it is also the most common subject for these controls.
|
||||
|
||||
Broad where [`blacks_whites`](blacks_whites.yaml) is narrow. These are the
|
||||
controls used to tame a contrasty scene; those are the ones used to place
|
||||
the endpoints.
|
||||
|
||||
placement: |
|
||||
After the broad tonal statement contrast makes, so these refine it.
|
||||
|
||||
params:
|
||||
highlights:
|
||||
label: param.highlights
|
||||
kind: amount
|
||||
doc: |
|
||||
Negative recovers highlights, the overwhelmingly common direction,
|
||||
matching the convention every other developer uses.
|
||||
shadows:
|
||||
label: param.shadows
|
||||
kind: amount
|
||||
|
||||
uniforms:
|
||||
hi_amount:
|
||||
value: highlights / 100
|
||||
doc: |
|
||||
A full stop at the extreme; enough to recover a bright sky without
|
||||
inverting the tonal relationship.
|
||||
lo_amount: shadows / 100
|
||||
|
||||
helpers: [luminance, tone_position, apply_tone_gain]
|
||||
|
||||
wgsl: |
|
||||
let luma = luminance(c);
|
||||
let pos = tone_position(luma);
|
||||
|
||||
// Broad, overlapping weights. Highlights ramp in over the upper half,
|
||||
// shadows out over the lower half, so a mid-tone is barely touched by
|
||||
// either and the two controls blend rather than fighting at the join.
|
||||
let hi_w = smoothstep(0.5, 1.0, pos);
|
||||
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
|
||||
|
||||
// Each control contributes up to a stop of gain at full deflection.
|
||||
// exp2 keeps the effect symmetric: -100 and +100 are inverse.
|
||||
let hi_gain = exp2(hi_amount * hi_w);
|
||||
let lo_gain = exp2(lo_amount * lo_w);
|
||||
|
||||
c = apply_tone_gain(c, hi_gain * lo_gain);
|
||||
|
||||
tests:
|
||||
- name: it_starts_neutral
|
||||
expect_active: false
|
||||
|
||||
- name: one_parameter_is_enough_to_activate
|
||||
set: { highlights: -50 }
|
||||
expect_active: true
|
||||
|
||||
- name: highlight_recovery_is_the_negative_direction
|
||||
why: The convention users expect — dragging left recovers. Full travel is one stop.
|
||||
set: { highlights: -100 }
|
||||
expect: { hi_amount: -1.0 }
|
||||
|
||||
- name: tonal_amounts_are_symmetric
|
||||
why: |
|
||||
exp2 of equal and opposite exponents multiplies to 1, so +100 and -100
|
||||
have to be exact negations for the two directions to cancel.
|
||||
set: { shadows: 100 }
|
||||
expect: { lo_amount: 1.0 }
|
||||
|
||||
- name: the_weights_are_smooth_not_thresholded
|
||||
why: |
|
||||
A hard boundary bands visibly on a gradient, and a sky is both the worst
|
||||
case and the most common subject for this control.
|
||||
expect_wgsl: ["smoothstep(0.5, 1.0, pos)", "smoothstep(0.0, 0.5, pos)"]
|
||||
@@ -0,0 +1,60 @@
|
||||
id: saturation
|
||||
label: op.saturation
|
||||
order: 90
|
||||
|
||||
doc: |
|
||||
Saturation — every colour's distance from grey, scaled equally.
|
||||
|
||||
The blunt instrument beside [`vibrance`](vibrance.yaml). Both are offered
|
||||
because they fail differently: this one is predictable and even, which is
|
||||
what a landscape wants, and ruinous on faces, which is what vibrance is for.
|
||||
|
||||
placement: |
|
||||
Last of the per-colour controls, so it has the final word if both it and
|
||||
vibrance are in play.
|
||||
|
||||
params:
|
||||
saturation:
|
||||
label: param.saturation
|
||||
kind: amount
|
||||
|
||||
uniforms:
|
||||
factor:
|
||||
value: max(1 + saturation / 100, 0)
|
||||
doc: |
|
||||
-100 reaches exactly monochrome; +100 doubles the distance from grey.
|
||||
The floor at zero matters: a negative factor would push a colour past
|
||||
grey into its complement, inverting hues.
|
||||
|
||||
helpers: [luminance, tone_position, apply_tone_gain]
|
||||
|
||||
wgsl: |
|
||||
// Interpolate away from the luminance-preserving grey. A factor of 0 is
|
||||
// monochrome, 1 is unchanged, above 1 is more saturated.
|
||||
let luma = luminance(c);
|
||||
c = mix(vec3<f32>(luma), c, factor);
|
||||
c = max(c, vec3<f32>(0.0));
|
||||
|
||||
tests:
|
||||
- name: it_starts_unchanged
|
||||
expect: { factor: 1.0 }
|
||||
expect_active: false
|
||||
|
||||
- name: full_negative_saturation_reaches_monochrome
|
||||
why: |
|
||||
The property that makes -100 meaningful: it must land exactly on grey,
|
||||
not merely near it.
|
||||
set: { saturation: -100 }
|
||||
expect: { factor: 0.0 }
|
||||
|
||||
- name: positive_saturation_increases_the_factor
|
||||
set: { saturation: 100 }
|
||||
expect: { factor: 2.0 }
|
||||
|
||||
- name: the_factor_never_goes_negative
|
||||
why: |
|
||||
A negative factor pushes a colour past grey into its complement, which
|
||||
inverts hues rather than desaturating them. The clamp is what makes the
|
||||
bottom of the slider's travel monochrome instead of a solarised image.
|
||||
set: { saturation: -100 }
|
||||
expect_range: { factor: [0.0, 2.0] }
|
||||
@@ -0,0 +1,23 @@
|
||||
# A hand-written node. `rust:` names the type in `crate::ops` that implements
|
||||
# `Operation`; everything else about it — its descriptor, its parameters, its
|
||||
# WGSL — comes from that type rather than from this file.
|
||||
#
|
||||
# It appears here anyway so that `ops/` lists the whole pipeline in order.
|
||||
# A chain half-declared here and half-ordered in Rust would be worse than
|
||||
# either alone: the order is the one thing a reader comes to this directory
|
||||
# to learn.
|
||||
id: tone_curve
|
||||
order: 60
|
||||
rust: ToneCurve
|
||||
|
||||
why_rust: |
|
||||
Five control points presented as one curve widget, with an interpolator and
|
||||
a monotonicity guarantee behind it. Its neutral is a *relationship* between
|
||||
parameters rather than a set of values — the identity diagonal — which is
|
||||
not something the declarative `active:` rule can express, and its
|
||||
`presentation()` spans parameters rather than describing one.
|
||||
|
||||
placement: |
|
||||
After the fixed-weight region controls, so the curve is the final word on
|
||||
tone: a photographer reaches for it to fix what those controls could not
|
||||
place exactly.
|
||||
@@ -0,0 +1,65 @@
|
||||
id: vibrance
|
||||
label: op.vibrance
|
||||
order: 80
|
||||
|
||||
doc: |
|
||||
Vibrance — saturation weighted toward the muted colours.
|
||||
|
||||
Where [`saturation`](saturation.yaml) scales every colour's distance from
|
||||
grey equally, vibrance scales it *more for muted colours than for already
|
||||
saturated ones*, and protects skin tones. The difference matters: pushing
|
||||
saturation on a portrait turns faces orange long before the background
|
||||
improves, which is precisely the problem vibrance was invented to solve.
|
||||
|
||||
placement: |
|
||||
Before saturation, so the broad control has the last word if both are used.
|
||||
|
||||
params:
|
||||
vibrance:
|
||||
label: param.vibrance
|
||||
kind: amount
|
||||
|
||||
uniforms:
|
||||
amount: vibrance / 100
|
||||
|
||||
helpers: [luminance, tone_position, colour_saturation]
|
||||
|
||||
wgsl: |
|
||||
let luma = luminance(c);
|
||||
let sat = colour_saturation(c);
|
||||
|
||||
// The vibrance curve: full effect on grey, tapering to nothing on colours
|
||||
// that are already saturated. Squaring the falloff keeps the mid-range
|
||||
// responsive while still protecting the extremes.
|
||||
let falloff = (1.0 - sat) * (1.0 - sat);
|
||||
|
||||
// Skin protection. Skin sits in a narrow band of hue where red leads green
|
||||
// leads blue; pushing it is what makes vibrance look wrong on portraits.
|
||||
// Detected by channel ordering rather than a hue angle, which costs a
|
||||
// conversion and buys nothing here.
|
||||
let is_skin = f32(c.r > c.g && c.g > c.b);
|
||||
let skin_guard = 1.0 - is_skin * 0.5;
|
||||
|
||||
let strength = amount * falloff * skin_guard;
|
||||
c = mix(vec3<f32>(luma), c, 1.0 + strength);
|
||||
c = max(c, vec3<f32>(0.0));
|
||||
|
||||
tests:
|
||||
- name: it_starts_neutral
|
||||
expect_active: false
|
||||
|
||||
- name: the_amount_is_normalised_to_unit_range
|
||||
set: { vibrance: 100 }
|
||||
expect: { amount: 1.0 }
|
||||
|
||||
- name: muted_colours_get_more_than_saturated_ones
|
||||
why: |
|
||||
The one property that distinguishes vibrance from saturation. Without
|
||||
the falloff term this node would be a duplicate of its neighbour.
|
||||
expect_wgsl: ["let falloff = (1.0 - sat) * (1.0 - sat);"]
|
||||
|
||||
- name: skin_tones_are_protected
|
||||
why: |
|
||||
The reason vibrance exists. A portrait pushed on plain saturation goes
|
||||
orange long before the background improves.
|
||||
expect_wgsl: ["let skin_guard = 1.0 - is_skin * 0.5;"]
|
||||
@@ -0,0 +1,86 @@
|
||||
id: white_balance
|
||||
label: op.white_balance
|
||||
order: 10
|
||||
|
||||
doc: |
|
||||
White balance — temperature and tint, relative to as-shot.
|
||||
|
||||
Expressed as an offset from what the camera chose rather than an absolute
|
||||
kelvin value. Neutral means "as shot", so the control starts where the
|
||||
image already is and a reset returns there. An absolute scale would make
|
||||
the neutral position depend on the file, which is exactly the confusion
|
||||
Lightroom's temperature slider creates on non-raw files.
|
||||
|
||||
The as-shot multipliers themselves are applied by the composer's preamble
|
||||
rather than here — every image has them even when this operation is
|
||||
neutral, so they cannot live in a fragment that vanishes at neutral.
|
||||
|
||||
placement: |
|
||||
First. It is a correction to how the scene was captured, and every tonal
|
||||
operation after it should act on a correctly balanced image.
|
||||
|
||||
params:
|
||||
temperature:
|
||||
label: param.temperature
|
||||
kind: amount
|
||||
doc: |
|
||||
Warmer is positive, matching every other raw developer: dragging right
|
||||
makes the image warmer, even though that means *lowering* the colour
|
||||
temperature being corrected for.
|
||||
tint:
|
||||
label: param.tint
|
||||
kind: amount
|
||||
|
||||
# Temperature trades red against blue; tint trades green against magenta.
|
||||
# Both are scaled so the full range is a strong but not destructive
|
||||
# correction: ±0.5 in log2 at the extremes — half a stop of channel shift,
|
||||
# which covers ordinary illuminant error without letting the slider blow a
|
||||
# channel on its own.
|
||||
uniforms:
|
||||
mul_r: exp2(temperature / 100 * 0.5)
|
||||
mul_g:
|
||||
value: exp2(-(tint / 100 * 0.5))
|
||||
doc: |
|
||||
Green is held at unity by temperature, so the control does not double as
|
||||
an exposure slider — green carries most of the luminance.
|
||||
mul_b:
|
||||
value: exp2(-(temperature / 100 * 0.5))
|
||||
doc: Blue moves opposite red, so a neutral grey stays grey as the control moves.
|
||||
|
||||
wgsl: |
|
||||
c = c * vec3<f32>(mul_r, mul_g, mul_b);
|
||||
|
||||
tests:
|
||||
- name: neutral_is_as_shot
|
||||
expect: { mul_r: 1.0, mul_g: 1.0, mul_b: 1.0 }
|
||||
expect_active: false
|
||||
|
||||
- name: warming_raises_red_and_lowers_blue
|
||||
set: { temperature: 100 }
|
||||
expect: { mul_r: 1.4142135, mul_b: 0.70710677 }
|
||||
|
||||
- name: temperature_leaves_green_alone
|
||||
why: |
|
||||
Otherwise the control doubles as an exposure slider, because green
|
||||
carries most of the luminance.
|
||||
set: { temperature: 100 }
|
||||
expect: { mul_g: 1.0 }
|
||||
|
||||
- name: tint_moves_green_against_magenta
|
||||
why: Positive tint reduces green, and must not touch red or blue.
|
||||
set: { tint: 100 }
|
||||
expect: { mul_g: 0.70710677, mul_r: 1.0, mul_b: 1.0 }
|
||||
|
||||
- name: cooling_is_the_inverse_of_warming
|
||||
why: |
|
||||
Warming by n then cooling by n must return to neutral, so the two
|
||||
directions have to be exact reciprocals rather than merely similar.
|
||||
set: { temperature: -100 }
|
||||
expect: { mul_r: 0.70710677, mul_b: 1.4142135 }
|
||||
|
||||
- name: the_extremes_stay_within_half_a_stop
|
||||
why: |
|
||||
A white balance control that can blow a channel by itself is a trap;
|
||||
correction belongs in a range where highlights survive.
|
||||
set: { temperature: 100, tint: 100 }
|
||||
expect_range: { mul_r: [0.70, 1.42], mul_g: [0.70, 1.42], mul_b: [0.70, 1.42] }
|
||||
Reference in New Issue
Block a user