studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
Two specs, one commit: the develop state becomes the panel column the Lightroom spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become shapes of the tone curve, the way the mapping spec measures them. The column. The left rail used to hand LIGHT a row of chips and nothing else; the four tone knobs were chips that opened a curve, and the rest of the develop state lived in the chip row's own vocabulary. `DevelopPanels` renders the five sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS — as an accordion, all open, and every parameter the recipe holds has a row in it with a `data-key` off the parameter name: slider, value readout, double-click to default. Sliders are always visible, so a knob is one drag away instead of two taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber, green through pink) so the direction is on the control. The PRO looks the spec marks stay in the list but locked, tagged PRO, and tapping one asks for PRO — they are shown, not hidden, and not silently dropped. WHITE and BLACK move out of the WB group. They were the temperature group's extremes, which is what a white balance control does — the toe and the shoulder of the same ramp — but the spec puts them with the tone knobs and gives them the two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights` and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives. A recipe written before this commit still reads: the keys are unchanged. The four knobs. The first pass of the mapping spec added a mask per zone onto the channel: `luma += knob * mask * intensity`, and the shader followed it. It is the wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the four masks are not a partition of the ramp. They sum to one at the ends and to zero at the midpoint, so an adjustment in the middle of a zone is applied where the mask is half and not at all where the mask has fallen to nothing, and the ramp inverts: with every knob at its stop the curve folds over itself, slope −5 at t=0.87, and the twin catches it as a non-monotone ramp. So each knob moves a knot on the curve instead, which is the reading the spec's own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50, HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a quarter of the range at that knot, so the range is 0.75..1.00 at the top and 0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`) by clamping each against its neighbour. Between knots the curve is a straight line, and 0.5 is untouched by every knob, so a knob at zero is the identity exactly rather than nearly, and any combination of the four is monotone. The mask sum survives where the spec is right about it: it hints the split between the two dark zones and the two light ones, nothing else. The hue is kept the way the spec keeps it: work in luma, then scale the chroma offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a saturated red stays the same red and only its brightness moves. The ratio is clamped to 0.55..1.35 because at luma near zero the division is the whole highlight of the picture on one code value. Verified: - `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader — four masks, four anchors, the four `mix` lines — and asserts the constructions it replaced are gone, then drives a twin of the ramp in JS: the masks do not overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162 combinations of the four knobs, every combination is monotone, the amplitude at each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded case from the additive build is in the harness as a regression. - `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`, `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check` and `white-level-check` all pass against the bundle — the catalogue, the RAW path, auto tone and the white level are untouched by the panel move. - Driven in a real browser (`tone-live-check.mjs`, Chromium against `vite preview`, a P1010256.JPG in the source control, mean luma of the preview canvas read before and after each knob): neutral 184.25, WHITE +1 187.35, BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80. Every knob moves the picture the way the spec says it should and none of them moves it much — a stop of a knob is a quarter of a zone, not a level. - The same run asserts the built DOM: five panels, the 23 `data-key` rows, `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and `dev-shadow` together in TONE, the gradient classes on the two white balance sliders, and the chip slots the panel is handed. The only failed request is `/api/events`, which is the backend this preview does not run. ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a per-channel rolloff in linear light — is gone, deleted rather than ported. The additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends, and the per-channel rolloff is a second knob for the same picture. Bring it back as its own parameter if a frame ever clips badly enough to need it. Also dropped: DR used to ride along as two additive terms. That is where the fold at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the ramp past its own end. It shifts the knots now, which is what the film sims always meant by it, and the numbers in the sims were kept and their meaning recommented (classic-chrome toe 0.22, head 0.7375, etc.). Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
@@ -105,6 +105,33 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.shadow ?? 0,
|
||||
set: (v) => ({ shadow: v }),
|
||||
},
|
||||
{
|
||||
// The two ends of the same ramp the four tone sliders move together (see
|
||||
// TONE_SKSL): WHITE is the knot on 1.00 and BLACK the one on 0.00, each
|
||||
// pulled toward the middle as the knob comes down. Not a white-balance
|
||||
// move any more, so they sit with the other two on the TONE panel. Same
|
||||
// range as the CREATE form's rows, so a look round-trips.
|
||||
key: 'whites',
|
||||
label: 'WHITE',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.whites ?? 0,
|
||||
set: (v) => ({ whites: v }),
|
||||
},
|
||||
{
|
||||
key: 'blacks',
|
||||
label: 'BLACK',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.blacks ?? 0,
|
||||
set: (v) => ({ blacks: v }),
|
||||
},
|
||||
],
|
||||
wb: [
|
||||
{
|
||||
@@ -129,32 +156,6 @@ export const PARAM_DEFS: {
|
||||
get: (a) => a.tint ?? 0,
|
||||
set: (v) => ({ tint: v }),
|
||||
},
|
||||
{
|
||||
// The two points live on WB, not on LIGHT: they shift each channel's own
|
||||
// end of the ramp, which balances a cast at the toe and the shoulder
|
||||
// rather than adding another tone knob. Same range as the CREATE form's
|
||||
// rows, so a look round-trips.
|
||||
key: 'whites',
|
||||
label: 'WHITE',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.whites ?? 0,
|
||||
set: (v) => ({ whites: v }),
|
||||
},
|
||||
{
|
||||
key: 'blacks',
|
||||
label: 'BLACK',
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 1,
|
||||
defaultValue: 0,
|
||||
display: sign,
|
||||
get: (a) => a.blacks ?? 0,
|
||||
set: (v) => ({ blacks: v }),
|
||||
},
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
|
||||
@@ -1,51 +1,73 @@
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
|
||||
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
|
||||
// SkSL runtime effect over a child image shader.
|
||||
// Tone-domain adjustments (the four-point tonal range + Fuji-style Dynamic
|
||||
// Range). SkSL runtime effect over a child image shader.
|
||||
//
|
||||
// Lightness/chroma split: the curve moves the luma and the colour difference
|
||||
// (rgb - luma) carries the hue through with most of its chroma. Scaling R,G,B
|
||||
// by one gain keeps the *ratio* but crushes absolute chroma — that is what
|
||||
// turned saturated blues black under -SH and bright colours grey under -HL.
|
||||
// TONAL RANGE — HIGHLIGHT, SHADOW, WHITE and BLACK. The four knobs are the
|
||||
// four zones of the tone mapping doc, and no two of them own the same part of
|
||||
// the ramp:
|
||||
//
|
||||
// Wide, soft knees so the knobs reach like a tone curve instead of biting only
|
||||
// at the very ends: HL rides the top (0.50..1.00) so it leaves the greys alone
|
||||
// (a knee that started lower dragged a mid-grey down) while SH rides the lower
|
||||
// half (0.00..0.55), and the 0.50 midpoint never moves.
|
||||
// BLACKS peak at 0.00, gone by 0.25
|
||||
// SHADOWS peak at 0.25, gone by 0.50
|
||||
// HIGHLIGHTS peak at 0.75, gone by 0.50 and by 1.00
|
||||
// WHITES peak at 1.00, gone by 0.75
|
||||
//
|
||||
// HL is two different controls with one knob, because recovery and a lift are
|
||||
// not the same move:
|
||||
// The four masks below are those tents — the doc's smoothsteps, one per quarter
|
||||
// of the ramp — and the 0.50 midpoint is in none of them: it is the one value
|
||||
// every one of the four leaves where it was.
|
||||
//
|
||||
// -HL is Lightroom's highlight RECOVERY: a soft knee in LINEAR light over the
|
||||
// top half (T = 0.5), pulled down by the ratio of the new luma to the old (see
|
||||
// the knee in TONE_SKSL). That is the shape the doc asks for, and the shape the
|
||||
// encoded domain cannot give — on the encoded value the last stop of headroom
|
||||
// is a few code values wide. It is the only term here that is not a shift, so
|
||||
// it is also the only one that can put detail back into a blown sky rather than
|
||||
// merely darken it. T=0.5 and S = |hl| keep it monotone (the slope leaves the
|
||||
// knee at 1 and falls, never rises) and it never brightens, so the frame cannot
|
||||
// invert.
|
||||
// Colour: the luma takes the move and R, G, B keep their RATIO, which is the
|
||||
// doc's `R_new = R_old * Luma_new / Luma_old`. One gain on all three channels
|
||||
// carries the hue through with the chroma, so a shadow lifted under a warm
|
||||
// light does not drift toward white. The `cg` clamp below is the one guard kept
|
||||
// on the ratio: past 1.35 it blows a dark saturated colour to white, and below
|
||||
// 0.55 it collapses a colour to black.
|
||||
//
|
||||
// +HL is a LIFT, weighted by the headroom that is left, (1 - t): the move falls
|
||||
// to zero as t reaches pure white, so a lamp or a specular is not turned grey,
|
||||
// and it rides into the upper midtones where a lift is wanted. The pull peaks
|
||||
// around t = 0.79 at 0.13 of the ramp for a full +10.
|
||||
// These four masks are ADDED in the doc's own pseudo-shader, and measured that
|
||||
// way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per
|
||||
// unit luma at t = 0.87 (scratchpad tone-proto.mjs), a dark band where the ramp
|
||||
// should still be climbing. Read here instead as the four ANCHORS of one ramp —
|
||||
// knots at 0.00, 0.25, 0.50, 0.75 and 1.00, each moved by its own knob, each
|
||||
// held inside the knot before it, drawn straight in between — the same
|
||||
// measurement is monotone for every combination of the four at full deflection.
|
||||
// A knob moves its anchor by a quarter of the ramp, so +10 BLACKS puts the toe
|
||||
// on 0.25 and -10 WHITES rolls the head down to 0.75: the reach a tonal range
|
||||
// slider has in the program this layout copies, without the inversion.
|
||||
//
|
||||
// SH stays an additive shift. Shifts keep the curve monotonic (worst slope
|
||||
// +0.003 at t = 0.99 with HL +10 and SH -10, and the two knees barely overlap),
|
||||
// so a brighter input can never come out darker. The earlier multiplicative
|
||||
// form was NOT monotonic: with hl=-1 a grey 0.73 came out darker than 0.80.
|
||||
// WHITE and BLACK are not the per-channel toe and shoulder they were on the WB
|
||||
// tab any more. The doc puts the two points on the ends of the SAME ramp as the
|
||||
// other two, so they are the ends this ramp is drawn through, and nothing else
|
||||
// in the shader reads them.
|
||||
//
|
||||
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights
|
||||
// (Fuji extended DR); 0/auto/DR100 = no extra curve.
|
||||
// hl - highlight -1..1: + lifts toward white, - rolls the bright side down.
|
||||
// sh - shadow -1..1: + lifts the dark side, - deepens it.
|
||||
// wh - white point -1..1: per channel, from the WB tab. + lifts the shoulder,
|
||||
// - rolls it down. Cubic weight in the channel's own value, so the
|
||||
// brighter channel of a highlight moves most.
|
||||
// bl - black point -1..1: per channel. + lifts the toe (faded black), -
|
||||
// crushes it; the darker channel of a shadow moves most.
|
||||
// Between two knots the ramp is drawn STRAIGHT, and that is deliberate: a
|
||||
// smoothstep there is an S-curve through the knots, so it bends the ramp by up
|
||||
// to six code values in the quarter-tones even with all four knobs on zero — and
|
||||
// this pass still runs for the stock split tones and for DR alone, where nothing
|
||||
// the user set asked for a contrast move. Straight segments keep a neutral
|
||||
// setting the exact identity. The smoothsteps are the four ZONE masks above,
|
||||
// which is where the doc's shape belongs: they weight DR and the split tones,
|
||||
// and nothing but their peak positions has to be smooth.
|
||||
//
|
||||
// The -HL highlight recovery that used to run in LINEAR light ahead of all this
|
||||
// is gone with it: HIGHLIGHT is one zone move now, in both directions. There is
|
||||
// still detail at the top to move — the develop's own knee compresses the two
|
||||
// stops the sensor holds above its white level into the frame (see
|
||||
// highlight-knee-check.mjs), so -WHITES pulls a plateau down onto 0.75 rather
|
||||
// than onto a flat 1.0.
|
||||
//
|
||||
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights (Fuji
|
||||
// extended DR); 0/auto/DR100 = no extra curve. It moves the same four
|
||||
// knots the knobs move, so DR and a knob cannot fight over the middle and
|
||||
// DR cannot invert the ramp either — added as its own masked terms on top
|
||||
// it could, and did: see the fold noted on the knots below.
|
||||
// hl - highlight -1..1: moves the 0.75 anchor, + up toward white, - down.
|
||||
// sh - shadow -1..1: moves the 0.25 anchor, + up, - down.
|
||||
// wh - white point -1..1: moves the 1.00 anchor. + is free to pass 1.0 — that
|
||||
// is the move that clips a highlight toward white — and - pulls the head
|
||||
// of the ramp down under it.
|
||||
// bl - black point -1..1: moves the 0.00 anchor. + lifts the toe off the
|
||||
// floor (a faded black), - has nothing left to crush at 0.
|
||||
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
|
||||
// (rather than the colour matrix) because it needs per-pixel chroma:
|
||||
// already-vivid pixels move least, so skins/skies deepen without the neon
|
||||
@@ -80,6 +102,13 @@ const BAND_BLOCK = hslBandGaps()
|
||||
)
|
||||
.join('');
|
||||
|
||||
// How far a tonal-range knob moves its own knot, in ramp units. A quarter is
|
||||
// the reach the program this layout copies gives a slider: at full deflection
|
||||
// the four together can put the toe on the midpoint, the head on it, or either
|
||||
// end on the quarter next to it — and never past, because each knot is clamped
|
||||
// inside the one before it.
|
||||
export const TONE_ANCHOR = 0.25;
|
||||
|
||||
export const TONE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float dr;
|
||||
@@ -147,72 +176,56 @@ float bandW(float hue, float anchor, float gapL, float gapR) {
|
||||
float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
|
||||
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
|
||||
}
|
||||
// The sRGB transfer pair, the accurate one (0.04045/12.92 + 2.4) — the same
|
||||
// constants colorUtils.planckianLinear uses on the WB side and EXPOSURE_SKSL
|
||||
// uses for its own pass. Highlight recovery needs the same space: a knee drawn
|
||||
// on the encoded value has the wrong shape (the midtones sit high and the last
|
||||
// stop of headroom is squeezed into a few code values), which is exactly the
|
||||
// doc's point about working in linear light.
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
|
||||
}
|
||||
vec3 toEncoded(vec3 c) {
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
|
||||
// Fraction of the way from e0 to e1, clamped — the position on one straight
|
||||
// segment of the tone ramp.
|
||||
float lin(float e0, float e1, float x) {
|
||||
return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
// HIGHLIGHT RECOVERY (-HL): the doc's soft knee, and it runs in LINEAR light,
|
||||
// before the luma and the masks below are read, so every later stage sees the
|
||||
// recovered value:
|
||||
// L' = L , L < T
|
||||
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
|
||||
// with T = 0.5 and S the knob. The pixel is rebuilt by the ratio L'/L, so
|
||||
// every channel keeps its share of the light and the hue and the saturation
|
||||
// cannot drift; the curve leaves T with the slope it arrived with (1), so
|
||||
// there is no seam at the knee; and the knee never brightens (S = 1 puts the
|
||||
// white point on 0.75), which is what a recovery slider has to do.
|
||||
if (hl < 0.0) {
|
||||
vec3 lin = toLinear(rgb);
|
||||
float l0 = dot(lin, vec3(0.2126, 0.7152, 0.0722));
|
||||
if (l0 > 0.5) {
|
||||
float over = l0 - 0.5;
|
||||
float S = min(-hl, 1.0);
|
||||
lin *= (0.5 + over / (1.0 + S * over * 2.0)) / l0;
|
||||
rgb = clamp(toEncoded(lin), 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
||||
float hlMask = smoothstep(0.50, 1.00, t);
|
||||
float shMask = 1.0 - smoothstep(0.00, 0.55, t);
|
||||
// NOTE: never name a local 'out' — it is a reserved SkSL qualifier.
|
||||
// The (1.0 - t) headroom weight is the whole point of the highlight curve: at
|
||||
// t = 1.0 the
|
||||
// weight is 0, so the pull-back cannot touch a pure white (a sun, a bulb, a
|
||||
// specular) and cannot turn it grey. A LIFT gets the same weight, which rides
|
||||
// it into the upper midtones and leaves the clipping where it was.
|
||||
// The LIFT only, so max(hl, 0): -HL has already been spent in linear light
|
||||
// above, and running it through this additive term as well would double-count
|
||||
// it (and, being an additive shift, would darken the white the knee just
|
||||
// protected).
|
||||
float o = t + max(hl, 0.0) * hlMask * (1.0 - t) + sh * 0.34 * shMask;
|
||||
// Dynamic range: gentle shadow lift + highlight roll (protect brights).
|
||||
o += dr * 0.12 * shMask * (1.0 - t);
|
||||
o -= dr * 0.18 * hlMask * t;
|
||||
float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
||||
// The four tents of the doc, one per quarter of the ramp: BLACKS peaks on
|
||||
// 0.00 and is gone by 0.25, SHADOWS peaks on 0.25 and is gone by 0.50,
|
||||
// HIGHLIGHTS peaks on 0.75 and is gone by 0.50 and 1.00, WHITES peaks on
|
||||
// 1.00 and is gone by 0.75. Each is the doc's own smoothstep, each is clipped
|
||||
// by subtracting the tent before it so the four never overlap and no luma is
|
||||
// ever counted twice, and the 0.50 midpoint is weighted by none of them: they
|
||||
// are the weights the stock split tones ride, which is why they are smooth and
|
||||
// why they stay out of the ramp below — nothing else in this shader reads them.
|
||||
float blMask = 1.0 - smoothstep(0.00, 0.25, t);
|
||||
float shMask = clamp(1.0 - smoothstep(0.25, 0.50, t) - blMask, 0.0, 1.0);
|
||||
float whMask = smoothstep(0.75, 1.00, t);
|
||||
float hlMask = clamp(smoothstep(0.50, 0.75, t) - whMask, 0.0, 1.0);
|
||||
// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and
|
||||
// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each
|
||||
// knot is held inside the one before it so the five can never cross. 0.50 is
|
||||
// fixed: it is the one point all four sliders leave alone, which is what
|
||||
// keeps a mid-grey a mid-grey while the ends move around it. Straight between
|
||||
// the knots, so every knob on zero is exactly the identity (see the note at
|
||||
// the head of this file).
|
||||
// DR moves the same knots instead of adding its own masked terms on top: it
|
||||
// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 —
|
||||
// 0.12 and 0.18 at full strength — and half of each at the knots next to
|
||||
// them, but because it is a knot move the ordering clamp holds it too. Added
|
||||
// as a separate term it could not: with BLACK and SHADOW both at -1 the ramp
|
||||
// is flat between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through
|
||||
// that stretch, which is a fold at 0.238.
|
||||
float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18;
|
||||
float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4);
|
||||
float a1 = clamp(0.25 + ${TONE_ANCHOR} * sh + dr * 0.06, 0.0, 0.5);
|
||||
float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1);
|
||||
float o = mix(a0, a1, lin(0.00, 0.25, t));
|
||||
o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t));
|
||||
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t));
|
||||
o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t));
|
||||
o = clamp(o, 0.0, 1.0);
|
||||
// Lightness takes the curve, hue stays: the colour difference is gained
|
||||
// only part-way so darkening cannot collapse a colour to black and lifting
|
||||
// cannot blow a dark saturated colour out to white.
|
||||
float cg = clamp(o / max(t, 0.0004), 0.55, 1.35);
|
||||
rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0);
|
||||
// WHITE/BLACK points, per channel. The weight is cubic in the channel's own
|
||||
// distance from the end, so in the shadows the darker channels move most and
|
||||
// in the highlights the brighter ones do: the two points pull R, G and B
|
||||
// toward a common toe and shoulder, which is a white-balance move (it
|
||||
// neutralises a cast) and not another tone slider. Each channel's curve is
|
||||
// still monotonic — 1 - 3*0.18 = 0.46 at worst — so no value can invert.
|
||||
vec3 dk = 1.0 - rgb;
|
||||
rgb = clamp(rgb + bl * 0.18 * dk * dk * dk + wh * 0.18 * rgb * rgb * rgb, 0.0, 1.0);
|
||||
// Split tone (stock look): the shadows and the highlights may each carry
|
||||
// their own tint, so the two ends of the curve can drift opposite ways
|
||||
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
|
||||
@@ -514,8 +527,8 @@ export interface ToneUniforms {
|
||||
dr: number; // 0..1
|
||||
hl: number; // -1..1 (adjustments.highlight / 10)
|
||||
sh: number; // -1..1 (adjustments.shadow / 10)
|
||||
wh: number; // -1..1 (adjustments.whites / 10 — WB white point, per channel)
|
||||
bl: number; // -1..1 (adjustments.blacks / 10 — WB black point, per channel)
|
||||
wh: number; // -1..1 (adjustments.whites / 10 — moves the 1.00 end of the ramp)
|
||||
bl: number; // -1..1 (adjustments.blacks / 10 — moves the 0.00 end of the ramp)
|
||||
vib: number; // -1..1 (adjustments.vibrance / 10)
|
||||
shT: [number, number, number]; // shadow split-tone RGB bias, -1..1
|
||||
hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1
|
||||
@@ -545,19 +558,20 @@ const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||||
// the two ENDS and leaves the middle an identity: a smooth shadow toe that
|
||||
// reaches a true black (no film-base lift, no flat grey wash) and a highlight
|
||||
// shoulder that stops just short of white instead of clipping a cloud to
|
||||
// paper. Mid-tones see neither mask, so the ramp keeps every step the matrix
|
||||
// handed over — which is what 'deep black' costs in a colour stock and does
|
||||
// not have to cost here.
|
||||
// Gains are TONE_SKSL's own (sh * 0.34, hl * 0.22), so -0.12 puts the toe at
|
||||
// ~5% and -0.05 trims the top ~1%.
|
||||
// paper. Mid-tones are between the 0.25 and the 0.75 knots, so they keep
|
||||
// every step the matrix handed over — which is what 'deep black' costs in a
|
||||
// colour stock and does not have to cost here.
|
||||
// The values move the two end knots of the ramp: -0.12 puts the toe on 0.22
|
||||
// and -0.05 rolls the head to 0.7375 (each is TONE_ANCHOR = 0.25 per unit).
|
||||
monochrome: { sh: -0.12, hl: -0.05 },
|
||||
// B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe
|
||||
// (-0.32 against Acros' -0.12) so the darks reach true black, and a shoulder
|
||||
// that LIFTS instead of rolling (-0.05 → +0.26), which is the whites step of
|
||||
// the brief. Midtones see neither mask, so the long smooth stretch between
|
||||
// the two ends survives — that is what keeps a hard push off the posterised
|
||||
// look, and the strength the stock needs on the greys is its matrix slope
|
||||
// (SIM_CONTRAST_BIAS in colorUtils), not another mask here.
|
||||
// (-0.32 against Acros' -0.12, so 0.17 against 0.22) so the darks reach true
|
||||
// black, and a shoulder that LIFTS instead of rolling (-0.05 → +0.26, the
|
||||
// head going to 0.815), which is the whites step of the brief. The stretch
|
||||
// between the two inner knots (0.25 and 0.75) is still the identity, so the
|
||||
// long smooth stretch of the greys survives — that is what keeps a hard push
|
||||
// off the posterised look, and the strength the stock needs on the greys is
|
||||
// its matrix slope (SIM_CONTRAST_BIAS in colorUtils), not another move here.
|
||||
'mono-high-contrast': { sh: -0.32, hl: 0.26 },
|
||||
};
|
||||
|
||||
@@ -569,6 +583,9 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
const wh = Math.max(-1, Math.min(1, (adj.whites ?? 0) / 10));
|
||||
const bl = Math.max(-1, Math.min(1, (adj.blacks ?? 0) / 10));
|
||||
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
|
||||
// WHITE and BLACK ride this pass with the other two, each as the end knot of
|
||||
// the same ramp (see TONE_SKSL). They are no longer a white-balance move and
|
||||
// are read by nothing else in the pipeline.
|
||||
const film = (baseFilter && FILM_TONE[baseFilter]) || {};
|
||||
const shT: [number, number, number] = film.shT ?? [0, 0, 0];
|
||||
const hlT: [number, number, number] = film.hlT ?? [0, 0, 0];
|
||||
|
||||
Reference in New Issue
Block a user