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:
@@ -1,20 +1,28 @@
|
||||
// Highlight roll-off, both ends of the pipeline, as one soft knee:
|
||||
// Highlight roll-off in the develop, and the tonal-range ramp in the tone pass.
|
||||
//
|
||||
// THE DEVELOP holds the knee:
|
||||
//
|
||||
// L' = L , L < T
|
||||
// L' = T + (L - T) / (1 + 2 S (L - T)) , L >= T
|
||||
//
|
||||
// The develop draws it on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)),
|
||||
// which puts the asymptote on 1.0) so the two stops the sensor holds above its
|
||||
// white level are COMPRESSED into the frame instead of being thrown away by the
|
||||
// old fade-to-white; the tone pass draws the same curve in linear light on the
|
||||
// value the develop and the camera match left, where -HL is the knob (T = 0.5,
|
||||
// S = |hl|). Before this, a blown sky left the develop on exactly 1.0 in all
|
||||
// three channels and HL had a flat white to pull on: measured on DSC03453.ARW,
|
||||
// where the camera's own preview is clipped the develop's luma was 253.4 with a
|
||||
// standard deviation of 2.4, against 251.2 / 10.0 through the knee.
|
||||
// drawn on the sensor's own levels (T = 0.7, S = 1 / (2 (1 - T)), which puts the
|
||||
// asymptote on 1.0) so the two stops the sensor holds above its white level are
|
||||
// COMPRESSED into the frame instead of being thrown away by the old
|
||||
// fade-to-white — which is also the only reason HIGHLIGHT has detail left at the
|
||||
// top to move. Measured on DSC03453.ARW, where the camera's own preview is
|
||||
// clipped, the develop's luma was 253.4 with a standard deviation of 2.4, against
|
||||
// 251.2 / 10.0 through the knee.
|
||||
//
|
||||
// Both are SkSL, so the shape is pinned on the source; the curve itself is
|
||||
// checked as arithmetic, with the constants the source is asserted to carry.
|
||||
// THE TONE PASS builds the luma a new ramp instead. The four knobs
|
||||
// (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc —
|
||||
// one tent each, one per quarter of the ramp — and each knob moves the knot it
|
||||
// owns by TONE_ANCHOR of the ramp, held inside the knot before it. The 0.50
|
||||
// midpoint is the one value all four leave where it was.
|
||||
//
|
||||
// Both are SkSL, so the SHAPE is pinned on the source; the arithmetic is then
|
||||
// checked against the source's own constants, and the ramp re-run here as a twin
|
||||
// so monotonicity, the neutral identity and the partition of the four masks are
|
||||
// checked rather than asserted in a comment.
|
||||
//
|
||||
// node scripts/highlight-knee-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -31,56 +39,203 @@ assert.match(dev, /float over = mx - 0\.7;/);
|
||||
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
|
||||
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
|
||||
|
||||
// The tone pass: the knee runs in LINEAR light and before the luma is read, the
|
||||
// lift keeps its headroom weight — and the recovery must not also ride the
|
||||
// additive term, which would darken the white the knee protects.
|
||||
assert.match(tone, /if \(hl < 0\.0\) \{/);
|
||||
assert.match(tone, /vec3 lin = toLinear\(rgb\);/);
|
||||
assert.match(tone, /float l0 = dot\(lin, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/);
|
||||
assert.match(tone, /lin \*= \(0\.5 \+ over \/ \(1\.0 \+ S \* over \* 2\.0\)\) \/ l0;/);
|
||||
assert.match(tone, /rgb = clamp\(toEncoded\(lin\), 0\.0, 1\.0\);/);
|
||||
assert.match(tone, /float o = t \+ max\(hl, 0\.0\) \* hlMask \* \(1\.0 - t\) \+ sh \* 0\.34 \* shMask;/);
|
||||
// The transfer pair has to be the accurate one, or the knee is drawn in a space
|
||||
// that is not linear at all.
|
||||
// The tone pass, read as the string it actually emits: TONE_ANCHOR is
|
||||
// interpolated, so the template has to be resolved before it can be matched.
|
||||
const anchorSrc = tone.match(/export const TONE_ANCHOR = ([0-9.]+);/)?.[1];
|
||||
assert.ok(anchorSrc, 'TONE_ANCHOR is gone — the four knots no longer share a reach');
|
||||
const A = Number(anchorSrc);
|
||||
assert.equal(A, 0.25, 'a knob no longer moves its knot a quarter of the ramp');
|
||||
const tmpl = tone.match(/export const TONE_SKSL = `([\s\S]*?)`;/)?.[1];
|
||||
assert.ok(tmpl, 'TONE_SKSL is gone');
|
||||
assert.equal((tmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
|
||||
const sksl = tmpl.replaceAll('${TONE_ANCHOR}', String(A));
|
||||
|
||||
// The four tents, one per quarter of the ramp, each clipped by its neighbour so
|
||||
// no luma is counted by two of them.
|
||||
assert.match(sksl, /float blMask = 1\.0 - smoothstep\(0\.00, 0\.25, t\);/);
|
||||
assert.match(sksl, /float shMask = clamp\(1\.0 - smoothstep\(0\.25, 0\.50, t\) - blMask, 0\.0, 1\.0\);/);
|
||||
assert.match(sksl, /float whMask = smoothstep\(0\.75, 1\.00, t\);/);
|
||||
assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask, 0\.0, 1\.0\);/);
|
||||
// The ramp: five knots, each moved by its own knob and held inside the one
|
||||
// before it. The 0.50 knot is a literal — nothing may move the midpoint. DR
|
||||
// moves the same knots, on the toe and the head exactly as it did when it was a
|
||||
// pair of masked terms (0.12 at t = 0, 0.18 at t = 1) and half of each at the
|
||||
// knots next to them.
|
||||
assert.match(sksl, /float a4 = 1\.0 \+ 0\.25 \* wh - dr \* 0\.18;/);
|
||||
assert.match(sksl, /float a3 = clamp\(0\.75 \+ 0\.25 \* hl - dr \* 0\.09, 0\.5, a4\);/);
|
||||
assert.match(sksl, /float a1 = clamp\(0\.25 \+ 0\.25 \* sh \+ dr \* 0\.06, 0\.0, 0\.5\);/);
|
||||
assert.match(sksl, /float a0 = clamp\(0\.25 \* bl \+ dr \* 0\.12, 0\.0, a1\);/);
|
||||
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
|
||||
// Straight between the knots, and NOT a smoothstep: an S-curve through the
|
||||
// knots bends the ramp by six code values in the quarter-tones with every knob
|
||||
// on zero, and this pass also runs for the stock split tones and for DR alone.
|
||||
assert.match(sksl, /float lin\(float e0, float e1, float x\) \{\n return clamp\(\(x - e0\) \/ \(e1 - e0\), 0\.0, 1\.0\);\n\}/);
|
||||
assert.match(sksl, /float o = mix\(a0, a1, lin\(0\.00, 0\.25, t\)\);/);
|
||||
assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, t\)\), step\(0\.25, t\)\);/);
|
||||
assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, t\)\), step\(0\.50, t\)\);/);
|
||||
assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, t\)\), step\(0\.75, t\)\);/);
|
||||
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
|
||||
// The linear-light knee that used to run ahead of all this is GONE from the tone
|
||||
// pass: HIGHLIGHT is one zone move in both directions now, and a second pass over
|
||||
// the same knob would double-count it.
|
||||
assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back');
|
||||
assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back');
|
||||
assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again');
|
||||
// The transfer pair has to be the accurate one where it is still used (the
|
||||
// exposure pass), or that pass is drawn in a space that is not linear at all.
|
||||
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
|
||||
|
||||
// The arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is the develop's pair (S is what
|
||||
// puts the asymptote on 1.0: T + 1/(2S) = 1); T = 0.5 with S = 1 is the top of
|
||||
// the tone pass's knob.
|
||||
// The develop's arithmetic. T = 0.7 / S = 1 / (2 (1 - T)) is its pair (S is what
|
||||
// puts the asymptote on 1.0: T + 1/(2S) = 1).
|
||||
const knee = (l, T, S) => (l < T ? l : T + (l - T) / (1 + 2 * S * (l - T)));
|
||||
const T = 0.7;
|
||||
const S = 1 / (2 * (1 - T));
|
||||
|
||||
for (const [T, S] of [
|
||||
[0.7, 1 / (2 * (1 - 0.7))],
|
||||
[0.5, 0.25],
|
||||
[0.5, 1],
|
||||
]) {
|
||||
// Below the knee the frame is untouched, and the curve is continuous and C1 at
|
||||
// T — slope 1 on both sides — so there is no seam for a later pass to mask.
|
||||
assert.equal(knee(T - 0.2, T, S), T - 0.2);
|
||||
assert.equal(knee(T, T, S), T);
|
||||
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
|
||||
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
|
||||
// Monotone, and never a brightening: a recovery slider that lifted a highlight
|
||||
// would be a lift in disguise, and an inverted pair of pixels is a visible edge.
|
||||
let prev = -Infinity;
|
||||
for (let l = 0; l <= 2; l += 1 / 512) {
|
||||
assert.ok(slope(l) > 0, `inverted at ${l} (T=${T}, S=${S})`);
|
||||
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
|
||||
assert.ok(knee(l, T, S) >= prev);
|
||||
prev = knee(l, T, S);
|
||||
}
|
||||
// The asymptote: everything the sensor held above the knee lands under it.
|
||||
// The develop's pair puts it exactly on 1.0, the tone pass's S = 1 on 0.75.
|
||||
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
|
||||
// Below the knee the frame is untouched, and the curve is continuous and C1 at T
|
||||
// — slope 1 on both sides — so there is no seam for a later pass to mask.
|
||||
assert.equal(knee(T - 0.2, T, S), T - 0.2);
|
||||
assert.equal(knee(T, T, S), T);
|
||||
const slope = (x) => (knee(x + 1e-6, T, S) - knee(x, T, S)) / 1e-6;
|
||||
assert.ok(Math.abs(slope(T) - 1) < 1e-3, `seam at T=${T}: slope ${slope(T)}`);
|
||||
// Monotone, and never a brightening: an inverted pair of pixels is a visible edge.
|
||||
let prev = -Infinity;
|
||||
for (let l = 0; l <= 2; l += 1 / 512) {
|
||||
assert.ok(slope(l) > 0, `inverted at ${l}`);
|
||||
assert.ok(knee(l, T, S) <= l + 1e-9, `brightened ${l} -> ${knee(l, T, S)}`);
|
||||
assert.ok(knee(l, T, S) >= prev);
|
||||
prev = knee(l, T, S);
|
||||
}
|
||||
assert.ok(Math.abs(0.7 + 1 / (2 * (1 / (2 * (1 - 0.7)))) - 1) < 1e-9, 'the develop plateau left 1.0');
|
||||
// The asymptote: everything the sensor held above the knee lands under it, on
|
||||
// exactly 1.0.
|
||||
assert.ok(Math.abs(knee(1e6, T, S) - (T + 1 / (2 * S))) < 1e-4);
|
||||
assert.ok(Math.abs(T + 1 / (2 * S) - 1) < 1e-9, 'the develop plateau left 1.0');
|
||||
// ...and the same pair in the encoded domain, which is the domain the develop
|
||||
// hands over: mx = 1.0 (the white level) lands on 237, the sensor's own plateau
|
||||
// (1.93, see the gain above) on 248 — a ramp of a dozen code values where the
|
||||
// old fade-to-white left nothing above 250 at all.
|
||||
// (1.93) on 248 — a ramp of a dozen code values where the old fade-to-white left
|
||||
// nothing above 250 at all. This is the headroom the four tone knobs move.
|
||||
const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055);
|
||||
const DEV_S = 1 / (2 * (1 - 0.7));
|
||||
assert.equal(Math.round(enc(knee(1.0, 0.7, DEV_S)) * 255), 237);
|
||||
assert.equal(Math.round(enc(knee(1.93, 0.7, DEV_S)) * 255), 248);
|
||||
assert.equal(Math.round(enc(knee(1.0, T, S)) * 255), 237);
|
||||
assert.equal(Math.round(enc(knee(1.93, T, S)) * 255), 248);
|
||||
|
||||
// The ramp as arithmetic — the same knots, the same lin() and the same step()
|
||||
// guards the SkSL above carries, so the shape is measured and not described.
|
||||
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
||||
// Float-exact comparisons are a trap once a value has been through a division
|
||||
// and a multiply (x / 0.25 * 0.25 is not x) — assert to within a code value.
|
||||
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-12, `${msg ?? ''} ${a} != ${b}`);
|
||||
const smoothstep = (e0, e1, x) => {
|
||||
const u = clamp01((x - e0) / (e1 - e0));
|
||||
return u * u * (3 - 2 * u);
|
||||
};
|
||||
const lin = (e0, e1, x) => clamp01((x - e0) / (e1 - e0));
|
||||
const step = (edge, x) => (x < edge ? 0 : 1);
|
||||
const mix = (a, b, t) => a + (b - a) * t;
|
||||
function ramp(t, k) {
|
||||
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
|
||||
const blMask = 1 - smoothstep(0, 0.25, t);
|
||||
const shMask = clamp01(1 - smoothstep(0.25, 0.5, t) - blMask);
|
||||
const whMask = smoothstep(0.75, 1, t);
|
||||
const hlMask = clamp01(smoothstep(0.5, 0.75, t) - whMask);
|
||||
const a4 = 1 + A * wh - dr * 0.18;
|
||||
const a3 = Math.min(a4, Math.max(0.5, 0.75 + A * hl - dr * 0.09));
|
||||
const a1 = Math.min(0.5, Math.max(0, 0.25 + A * sh + dr * 0.06));
|
||||
const a0 = Math.min(a1, Math.max(0, A * bl + dr * 0.12));
|
||||
let o = mix(a0, a1, lin(0, 0.25, t));
|
||||
o = mix(o, mix(a1, 0.5, lin(0.25, 0.5, t)), step(0.25, t));
|
||||
o = mix(o, mix(0.5, a3, lin(0.5, 0.75, t)), step(0.5, t));
|
||||
o = mix(o, mix(a3, a4, lin(0.75, 1, t)), step(0.75, t));
|
||||
return { o: clamp01(o), blMask, shMask, hlMask, whMask, maskSum: blMask + shMask + hlMask + whMask };
|
||||
}
|
||||
|
||||
// The tents never overlap — each is the doc's smoothstep minus the tent before
|
||||
// it, so the four together never count a luma twice — and the middle is the
|
||||
// quiet part: the ends of the ramp are weighted at 1, the 0.50 midpoint by
|
||||
// nothing at all. That is what leaves DR and the stock split tones on the two
|
||||
// ends and the mid-grey still.
|
||||
for (let t = 0; t <= 1; t += 1 / 512) {
|
||||
const { maskSum } = ramp(t, {});
|
||||
assert.ok(maskSum >= -1e-15 && maskSum <= 1 + 1e-15, `masks overlap at ${t}: ${maskSum}`);
|
||||
if (t <= 0.25 || t >= 0.75) assert.ok(Math.abs(maskSum - 1) < 1e-12, `end of the ramp unweighted at ${t}`);
|
||||
if (Math.abs(t - 0.5) < 1e-12) assert.equal(maskSum, 0, 'the midpoint is weighted');
|
||||
}
|
||||
// The neighbouring tents cross at half weight ON the knot between them, and the
|
||||
// 0.50 midpoint is where all four are on zero — the quiet value, and the reason
|
||||
// a mid-grey does not move while the ends do.
|
||||
assert.equal(ramp(0.125, {}).blMask, 0.5);
|
||||
assert.equal(ramp(0.125, {}).blMask, ramp(0.125, {}).shMask);
|
||||
assert.equal(ramp(0.25, {}).shMask, 1);
|
||||
assert.equal(ramp(0.25, {}).blMask, 0);
|
||||
assert.equal(ramp(0.875, {}).hlMask, ramp(0.875, {}).whMask);
|
||||
assert.equal(ramp(0.5, {}).maskSum, 0);
|
||||
assert.equal(ramp(0.75, {}).hlMask, 1);
|
||||
// Every knob on zero is EXACTLY the identity — the pass also runs for the stock
|
||||
// split tones and for DR alone, so a neutral setting must not curve the frame.
|
||||
for (let t = 0; t <= 1; t += 1 / 256) close(ramp(t, {}).o, t, `identity broke at ${t}`);
|
||||
// The midpoint is the one value no knob reaches, at any setting.
|
||||
for (const k of [{ hl: 1, sh: 1, wh: 1, bl: 1 }, { hl: -1, sh: -1, wh: -1, bl: -1 }, { hl: 1, sh: -1, wh: -1, bl: 1 }])
|
||||
close(ramp(0.5, k).o, 0.5, 'a knob moved the midpoint');
|
||||
|
||||
// Monotone under EVERY combination of the four at full deflection, DR included.
|
||||
// This is the whole reason the knots exist instead of the doc's additive masks,
|
||||
// which measured a slope of -5 per unit luma on BLACK +1 against SHADOW -1 (an
|
||||
// inverted band at t = 0.875, scratchpad tone-proto.mjs): every knot is clamped
|
||||
// inside the one before it, so the ramp cannot fold.
|
||||
const combos = [];
|
||||
for (const bl of [-1, 0, 1])
|
||||
for (const sh of [-1, 0, 1])
|
||||
for (const hl of [-1, 0, 1])
|
||||
for (const wh of [-1, 0, 1])
|
||||
for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr });
|
||||
let worst = Infinity;
|
||||
for (const k of combos) {
|
||||
let prev = null;
|
||||
for (let t = 0; t <= 1; t += 1 / 512) {
|
||||
const o = ramp(t, k).o;
|
||||
if (prev !== null) {
|
||||
assert.ok(o >= prev - 1e-12, `ramp folded at ${t} for ${JSON.stringify(k)}`);
|
||||
if (o - prev < worst) worst = o - prev;
|
||||
}
|
||||
prev = o;
|
||||
}
|
||||
}
|
||||
assert.ok(worst > -1e-12, `worst step ${worst} — the ramp is folded`);
|
||||
// A knob moves its own quarter, and only its own: +BLACK takes the toe off the
|
||||
// floor, +SHADOW puts the 0.25 knot on the midpoint, -HIGHLIGHT pulls the 0.75
|
||||
// knot onto it, and WHITE - rolls the head under 1.0. That is the reach a
|
||||
// tonal-range slider has — a quarter of the ramp, so the middle stays a middle.
|
||||
close(ramp(0, {}).o, 0, 'a neutral toe moved');
|
||||
close(ramp(0, { bl: 1 }).o, A, 'BLACK no longer reaches a quarter of the ramp');
|
||||
close(ramp(0.25, { sh: 1 }).o, 0.5, 'SHADOW no longer reaches the midpoint');
|
||||
close(ramp(0.75, { hl: -1 }).o, 0.5, 'HIGHLIGHT no longer reaches the midpoint');
|
||||
close(ramp(1, { wh: -1 }).o, 0.75, 'WHITE no longer rolls the head under 1.0');
|
||||
close(ramp(0.25, {}).o, 0.25, 'a neutral knot moved');
|
||||
close(ramp(0.75, {}).o, 0.75, 'a neutral knot moved');
|
||||
// WHITE + is free to pass 1.0 — that is the move that clips a highlight to
|
||||
// white — and the ramp still runs through a raised knot at 1.25.
|
||||
assert.ok(1 + A * 1 > 1, 'the white knot can no longer pass 1.0');
|
||||
close(ramp(1, { wh: 1 }).o, 1, 'a raised white knot left the top of the ramp');
|
||||
// DR at full is the same curve it was: the toe on 0.12 and the head on 0.82,
|
||||
// which is what the two masked terms added at t = 0 and t = 1, and the midpoint
|
||||
// still untouched. Now it is a knot move, so BLACK and SHADOW both at -1 (a flat
|
||||
// stretch between 0.25 and 0.5, where the old additive lift sloped down and
|
||||
// folded the ramp at 0.238) stays monotone.
|
||||
close(ramp(0, { dr: 1 }).o, 0.12, 'DR no longer lifts the toe the way it did');
|
||||
close(ramp(1, { dr: 1 }).o, 0.82, 'DR no longer rolls the head the way it did');
|
||||
close(ramp(0.5, { dr: 1 }).o, 0.5, 'DR moved the midpoint');
|
||||
// Black and shadow both at -1 are the flat stretch DR used to fold: the toe is
|
||||
// held on the floor by the ordering clamp (BLACK's -0.25 cancels DR's +0.12),
|
||||
// the 0.25 knot is DR's own +0.06, and the stretch between them is a straight
|
||||
// line up to the midpoint — never a step down.
|
||||
assert.equal(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0);
|
||||
assert.equal(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.06);
|
||||
close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28, 'DR folded the flat stretch');
|
||||
// The two ends stay ordered even at full deflection against each other: the toe
|
||||
// can never climb past the head.
|
||||
for (const bl of [-1, 1])
|
||||
for (const wh of [-1, 1]) {
|
||||
const toe = ramp(0, { bl, sh: 1, wh }).o;
|
||||
const head = ramp(1, { bl, wh, hl: -1 }).o;
|
||||
assert.ok(toe <= head + 1e-12, `toe ${toe} over head ${head}`);
|
||||
}
|
||||
|
||||
console.log('highlight-knee-check ok');
|
||||
|
||||
Reference in New Issue
Block a user