One move of the light for the whole app: a mask's EXPOSURE and its four tone knobs stop being a second opinion
A gradient mask had its own tone formula and its own exposure, and both disagreed with the frame's. Before any of this was tidied, the mask ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp while the frame moved the knots of a four-zone ramp, and the mask's exposure was a stop on sRGB-encoded values while the frame's was a stop on light. Two names, four moves, and the same slider meant different things depending on whether the pixels were inside the shape you drew — the divergence §3.3 of the Android port's compat doc warns about. The maths is one string now (TONE_MATH_SKSL, interpolated by both passes): the ramp the four knots build, the hue-preserving rebuild behind it, the transfer pair, and exposureMove. A mask calls the same functions the frame calls. The rebuild carries the chroma instead of re-scaling it. Lightness takes the curve and the colour rides the difference — the channel differences move by ONE shared scale k, pulled back only where the cube has no room left. The doc's ratio (R_new = R_old * Luma_new / Luma_old) was the old reading and it is exact only while nothing clips: a channel past 1.0 stops being scaled with its neighbours and the hue goes with it. Measured on a flat patch frame, a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at 57.4°, and under L = 0.5 the same ratio multiplied a near-black pixel's cast by x30 — colour noise amplified, which is why the 0.55..1.35 clamp was there. The scale is the chroma's own now: over 135 knob combinations on seven colours and five greys, the ramp moves the luma and the hue does not move at all (Δ < 1e-9°). EXPOSURE gets the same treatment, which is what the second half of the request was: the linear domain decides where the luma is going, and the pixel is rebuilt onto it through the same lightMove. The old pass multiplied the three channels in linear light, so +1 EV clipped them by three different amounts: measured on the scratchpad probe, 29.2° of hue drift on a skin tone at +1 EV and 33.3° at +2, against 0.00° here. The stop is applied as a ratio on the pixel's own encoded luma rather than pointed straight at the encoded linear target, which is what makes the knob exactly the identity at 0 EV — the transfer does not commute with the luma weights, so pointing at it brightened a colour by a couple of code values even at zero. A grey is the knob it always was: 128 through +1 EV is 176, the same number the linear per-channel multiply put there, so nothing a user has dialled in moves. Verified: `npx tsc --noEmit` clean, `npm run build` clean. highlight-knee-check now runs EXPOSURE_SKSL for real — compiled with CanvasKit and four pixels pushed through it, agreeing with the twin to a code value on a grey at +1 EV (176), a skin tone at +1 EV and +2 EV, and a shadow at -2 EV; it also pins the hue, the cube, the identity at 0 EV and the black pixel that has no light to move. mask-wb-check compiles the mask pass and pushes the same stops through it: 128 through +1 EV is 176, through -1 EV is 92, +2 EV lands the channel on the ceiling at 255 and holds the hue within 3°. auto-tone-check, preview-match-check, white-level-check, raw-develop-check, half-check and roll-walk-check all pass. Live on the built bundle in a 1440x950 browser: the LIGHT panel's EXPOSURE +1 EV takes the mid grey of a flat patch frame from 0.502 to 0.690 (a stop on light gives 0.686) and moves no patch's hue at -1 EV (Δ 0.00°), and a linear gradient mask's EXPOSURE +1 EV and HIGHLIGHT +100 move the pixels inside the mask (luma 185.9 -> 211.1 and 185.9 -> 197.5) while the corner outside it does not move at all (220.2 -> 220.2), with no console error. Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
// node scripts/highlight-knee-check.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const develop = readFileSync(new URL('../src/engine/rawDevelop.ts', import.meta.url), 'utf8');
|
||||
const tone = readFileSync(new URL('../shared/utils/toneShader.ts', import.meta.url), 'utf8');
|
||||
@@ -47,8 +48,27 @@ 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 ramp, the hue-preserving rebuild and the exposure move live in
|
||||
// TONE_MATH_SKSL, the one copy the whole-frame pass and a gradient mask both
|
||||
// interpolate — so the shape is pinned there, and TONE_SKSL has to reach for it
|
||||
// rather than carry a second version of its own (that is the divergence the
|
||||
// compat doc §3.3 warns the Android port about).
|
||||
const mathTmpl = tone.match(/export const TONE_MATH_SKSL = `([\s\S]*?)`;/)?.[1];
|
||||
assert.ok(mathTmpl, 'TONE_MATH_SKSL is gone — the frame and a mask no longer share the maths');
|
||||
assert.equal((mathTmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
|
||||
assert.ok(tmpl.includes('${TONE_MATH_SKSL}'), 'the frame pass carries its own copy of the ramp again');
|
||||
assert.match(tmpl, /rgb = toneRamp\(rgb, t, bl, sh, hl, wh, dr\);/);
|
||||
const resolve = (s) => s.replace('${TONE_MATH_SKSL}', mathTmpl).replaceAll('${TONE_ANCHOR}', String(A));
|
||||
|
||||
const sksl = resolve(tmpl);
|
||||
const maths = resolve(mathTmpl);
|
||||
assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — one copy, not two');
|
||||
const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8');
|
||||
assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths');
|
||||
assert.match(mask, /c = half3\(exposureMove\(vec3\(c\), a\.x\)\);/);
|
||||
assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/);
|
||||
assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary saturation clamp');
|
||||
assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula');
|
||||
|
||||
// The four tents, one per quarter of the ramp, each clipped by its neighbour so
|
||||
// no luma is counted by two of them.
|
||||
@@ -87,10 +107,11 @@ assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK a
|
||||
// channel clips, and a clipped channel is a moved hue (a skin tone at 24.0° came
|
||||
// back at 48.0° at HIGHLIGHT +100, scratchpad hl-variants.mjs), and under L=0.5
|
||||
// it multiplies a near-black pixel's cast by up to x30.
|
||||
assert.match(sksl, /float k = 1\.0;/);
|
||||
assert.match(sksl, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/);
|
||||
assert.match(sksl, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/);
|
||||
assert.match(sksl, /rgb = clamp\(vec3\(o\) \+ \(rgb - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
|
||||
assert.match(maths, /float k = 1\.0;/);
|
||||
assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/);
|
||||
assert.match(maths, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/);
|
||||
assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
|
||||
assert.match(maths, /return lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\);/);
|
||||
assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back');
|
||||
assert.doesNotMatch(sksl, /max\(t, 0\.0004\)/, 'the luma ratio came back');
|
||||
// The transfer pair has to be the accurate one where it is still used (the
|
||||
@@ -253,17 +274,44 @@ for (const bl of [-1, 1])
|
||||
// The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own
|
||||
// chroma difference, and the one scale the cube allows.
|
||||
const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]);
|
||||
// lightMove, as TONE_MATH_SKSL emits it — the one move every brightness change in
|
||||
// the pass goes through (a tone knob, a mask's tone knob, the exposure knob).
|
||||
// NOT clamped on the way out here: the check below wants to see that the scale
|
||||
// alone already landed the pixel inside the cube, and a silent clamp would hide
|
||||
// the case where it did not.
|
||||
function lightMove(rgb, t, o) {
|
||||
let k = 1;
|
||||
const hiC = Math.max(...rgb);
|
||||
const loC = Math.min(...rgb);
|
||||
if (hiC > t) k = Math.min(k, (1 - o) / (hiC - t));
|
||||
if (loC < t) k = Math.min(k, o / (t - loC));
|
||||
return rgb.map((c) => o + (c - t) * k);
|
||||
}
|
||||
function rebuild(rgb, k) {
|
||||
const t = lumaOf(rgb);
|
||||
const o = ramp(t, k).o;
|
||||
const hiC = Math.max(...rgb);
|
||||
const loC = Math.min(...rgb);
|
||||
let s = 1;
|
||||
if (hiC > t) s = Math.min(s, (1 - o) / (hiC - t));
|
||||
if (loC < t) s = Math.min(s, o / (t - loC));
|
||||
const out = rgb.map((c) => o + (c - t) * s);
|
||||
const out = lightMove(rgb, t, o);
|
||||
return { out, clamped: out.map((c) => clamp01(c)), o, t };
|
||||
}
|
||||
// The transfer pair the exposure pass crosses into linear light with, and back.
|
||||
const srgbToLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
const linToSrgb = (c) => (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055);
|
||||
// exposureMove, as TONE_MATH_SKSL emits it: the linear sensor moves by the stops,
|
||||
// and the encoded value that lands there is the luma the pixel is rebuilt onto.
|
||||
// The light moves by exp2(ev) in LINEAR light; the colour moves by the one shared
|
||||
// scale of lightMove. A per-channel multiply does neither — it clips the three
|
||||
// channels by three different amounts and takes the hue with it (29.2° at +1 EV
|
||||
// on the scratchpad probe, exp-variant.mjs; this variant measures 0.00°).
|
||||
function exposureMove(rgb, ev) {
|
||||
const c = rgb.map(clamp01);
|
||||
const t = lumaOf(c);
|
||||
// The stop as a RATIO on the pixel's own encoded luma, which is what makes the
|
||||
// knob the identity at 0 EV: pointing the luma straight at the encoded linear
|
||||
// target brightens a colour by a couple of code values even on zero.
|
||||
const lin = Math.max(lumaOf(c.map(srgbToLin)), 1e-6);
|
||||
const stop = linToSrgb(Math.min(1, lin * 2 ** ev)) / linToSrgb(lin);
|
||||
return lightMove(c, t, clamp01(t * stop)).map(clamp01);
|
||||
}
|
||||
function hueOf(c) {
|
||||
const mx = Math.max(...c), mn = Math.min(...c), d = mx - mn;
|
||||
if (d < 1e-9) return NaN;
|
||||
@@ -322,4 +370,94 @@ const carried = rebuild([0.7, 0.55, 0.45], { hl: 0.5 }).clamped;
|
||||
const grew = (carried[0] - carried[1]) / (0.7 - 0.55);
|
||||
close(grew, 1, 'the chroma was re-scaled on a highlight lift');
|
||||
|
||||
// THE EXPOSURE KNOB, the same move on a different input. Behind it: -2..+2 EV in
|
||||
// half stops, on the frame and inside a gradient mask.
|
||||
// A grey is the knob it always was — a stop on a neutral is a stop on its light,
|
||||
// and nothing else: this is the number the old linear per-channel multiply put
|
||||
// there, so no exposure a user has dialled in moves.
|
||||
for (const g of [0.05, 0.1, 0.5, 0.7, 0.9, 0.97])
|
||||
for (const ev of [-2, -1, -0.5, 0.5, 1, 2])
|
||||
close(
|
||||
exposureMove([g, g, g], ev)[0],
|
||||
linToSrgb(Math.min(1, srgbToLin(g) * 2 ** ev)),
|
||||
`the exposure is no longer a stop on a grey: ${g} at ${ev} EV`,
|
||||
);
|
||||
// ...and zero stops is the identity on a COLOUR too, exactly — the knob has to be
|
||||
// able to leave the frame alone.
|
||||
for (const rgb of [...colourCases, ...greyCases])
|
||||
for (let i = 0; i < 3; i++)
|
||||
close(exposureMove(rgb, 0)[i], rgb[i], 'the exposure move is not the identity at 0 EV');
|
||||
// Hue cannot move, at any stop, on any colour: this is the whole fix. The old
|
||||
// pass multiplied the three channels by the same number in LINEAR light and then
|
||||
// clipped them by three different amounts, and the hue went with them — 29.2° on
|
||||
// the skin tone at +1 EV, 33.3° at +2 EV (scratchpad exp-variant.mjs), against
|
||||
// 0.00° here.
|
||||
for (const rgb of colourCases)
|
||||
for (const ev of [-2, -1, -0.5, 0, 0.5, 1, 2]) {
|
||||
const out = exposureMove(rgb, ev);
|
||||
const dh = hueOf(out) - hueOf(rgb);
|
||||
assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `the exposure moved the hue ${dh}° on ${rgb} at ${ev} EV`);
|
||||
assert.ok(out.every((c) => c >= -1e-12 && c <= 1 + 1e-12), `the exposure left the cube on ${rgb} at ${ev} EV`);
|
||||
}
|
||||
// A pixel already on the ceiling: the channel that used to clip lands exactly ON
|
||||
// the ceiling and the other two follow it down at the one shared scale, so the
|
||||
// pixel gives up saturation rather than having the three clip by three different
|
||||
// amounts — which is where the old pass lost the hue.
|
||||
const blown = exposureMove([1, 0.6, 0.2], 5);
|
||||
assert.ok(blown.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure overshot the ceiling');
|
||||
close(blown[0], 1, 'the channel that hit the ceiling stopped short of it');
|
||||
assert.ok(blown[1] > 0.9 && blown[2] > 0.85, 'the pixel collapsed to white instead of keeping its colour');
|
||||
assert.ok(Math.abs(hueOf(blown) - hueOf([1, 0.6, 0.2])) < 1e-9, 'the pixel lost its hue at the ceiling');
|
||||
// ...and a pixel the move really does drive to 1.0 (an exposure past the head of
|
||||
// the ramp) is white, in all three channels at once.
|
||||
const white = exposureMove([0.98, 0.98, 0.98], 5);
|
||||
for (let i = 0; i < 3; i++) close(white[i], 1, 'a blown pixel stopped short of white');
|
||||
// Darkening is the mirror: the light comes down, and a colour with no room below
|
||||
// gives up saturation and arrives neutral, not negative.
|
||||
const crushed = exposureMove([0.02, 0.01, 0.005], -5);
|
||||
assert.ok(crushed.every((c) => c >= -1e-12 && c <= 1 + 1e-12), 'the exposure went outside the cube on the way down');
|
||||
assert.ok(crushed[0] >= crushed[1] && crushed[1] >= crushed[2], 'the exposure inverted the channel order on the way down');
|
||||
// Black has no light to move: every stop leaves it where it is, and none of them
|
||||
// divides by zero on the way.
|
||||
for (const ev of [-5, -1, 0, 1, 5]) assert.equal(exposureMove([0, 0, 0], ev)[0], 0, `black moved at ${ev} EV`);
|
||||
|
||||
// THE PASS ITSELF, compiled and run. Everything above is a twin, and a twin is
|
||||
// only as good as its reading of the source; nothing else compiles EXPOSURE_SKSL,
|
||||
// so a wrapper whose uniform stopped matching its own main would only show up in
|
||||
// the app. Four pixels through the real shader, against the twin.
|
||||
const exposureSrc = resolve(tone.match(/export const EXPOSURE_SKSL = `([\s\S]*?)`;/)?.[1] ?? '');
|
||||
assert.match(exposureSrc, /uniform float ev;/, 'the exposure pass no longer takes its stops');
|
||||
assert.match(exposureSrc, /return vec4\(exposureMove\(clamp\(c\.rgb, 0\.0, 1\.0\), ev\), c\.a\);/, 'the pass stopped calling exposureMove');
|
||||
const { default: CanvasKitInit } = await import('canvaskit-wasm/bin/full/canvaskit.js');
|
||||
const ck = await CanvasKitInit({
|
||||
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
|
||||
});
|
||||
const effect = ck.RuntimeEffect.Make(exposureSrc);
|
||||
assert.ok(effect, 'EXPOSURE_SKSL does not compile — the whole frame loses its exposure');
|
||||
const throughPass = (rgb, ev) => {
|
||||
const surface = ck.MakeSurface(4, 4);
|
||||
const paint = new ck.Paint();
|
||||
paint.setColor(ck.Color(...rgb));
|
||||
surface.getCanvas().drawPaint(paint);
|
||||
const child = surface.makeImageSnapshot().makeShaderOptions(
|
||||
ck.TileMode.Clamp, ck.TileMode.Clamp, ck.FilterMode.Linear, ck.MipmapMode.None,
|
||||
);
|
||||
const shaderPaint = new ck.Paint();
|
||||
shaderPaint.setShader(effect.makeShaderWithChildren([ev], [child]));
|
||||
const out = ck.MakeSurface(4, 4);
|
||||
out.getCanvas().drawRect(ck.XYWHRect(0, 0, 4, 4), shaderPaint);
|
||||
const px = out.getCanvas().readPixels(0, 1, {
|
||||
width: 4, height: 1, colorType: ck.ColorType.RGBA_8888, alphaType: ck.AlphaType.Unpremul, colorSpace: ck.ColorSpace.SRGB,
|
||||
});
|
||||
return [px[0], px[1], px[2]];
|
||||
};
|
||||
for (const [rgb, ev] of [[[128, 128, 128], 1], [[230, 150, 50], 1], [[230, 150, 50], 2], [[20, 10, 5], -2]]) {
|
||||
const want = exposureMove(rgb.map((v) => v / 255), ev).map((v) => Math.round(v * 255));
|
||||
const got = throughPass(rgb, ev);
|
||||
assert.ok(
|
||||
got.every((v, i) => Math.abs(v - want[i]) <= 1),
|
||||
`the pass and its twin disagree on ${rgb} at ${ev} EV: ${got} against ${want}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('highlight-knee-check ok');
|
||||
|
||||
@@ -40,7 +40,7 @@ writeFileSync(
|
||||
.replace(/^import .*from ['"]\.\/colorUtils['"];$/m, 'import { whiteBalanceGain } from "./colorUtils.mjs";')
|
||||
.replace(
|
||||
/^import \{[^\n}]*\} from ['"]\.\/toneShader['"];$/m,
|
||||
'import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS } from "./toneShader.mjs";',
|
||||
'import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA, DEHAZE_PATCH_STEP, DEHAZE_PATCH_TAPS, TONE_MATH_SKSL } from "./toneShader.mjs";',
|
||||
),
|
||||
);
|
||||
const { readMasks, maskUniforms, gradientMaskSkSL } = await import(
|
||||
@@ -125,14 +125,14 @@ const CanvasKit = await CanvasKitInit({
|
||||
const SIZE = 64;
|
||||
const wide = { kind: 'radial', x: 0.5, y: 0.5, ex: 0.5, ey: 0.5, rx: 4, ry: 4, angle: 0, feather: 0.5 };
|
||||
|
||||
function render(masks, spatial) {
|
||||
function render(masks, spatial, fillRGB = [128, 128, 128]) {
|
||||
const effect = CanvasKit.RuntimeEffect.Make(gradientMaskSkSL(masks.length, spatial));
|
||||
assert.ok(effect, `the mask shader for ${masks.length} masks does not compile (spatial ${spatial})`);
|
||||
// The frame the pass reads: a flat mid-grey, drawn once and handed in as the
|
||||
// first child exactly as exportEngine's replaceThrough hands its snapshot in.
|
||||
const src = CanvasKit.MakeSurface(SIZE, SIZE);
|
||||
const fill = new CanvasKit.Paint();
|
||||
fill.setColor(CanvasKit.Color(128, 128, 128));
|
||||
fill.setColor(CanvasKit.Color(...fillRGB));
|
||||
src.getCanvas().drawPaint(fill);
|
||||
const grey = src.makeImageSnapshot();
|
||||
const asChild = () =>
|
||||
@@ -174,6 +174,36 @@ assert.ok(spatialPx[0] > 130 && spatialPx[2] < 126, `the spatial pass dropped th
|
||||
const two = render([knob({}), knob({ temperature: 10000 })], false);
|
||||
assert.ok(two[0] > 130 && two[2] < 126, `the second mask's WB did not land: ${two}`);
|
||||
|
||||
// --- EXPOSURE inside a mask, which is now the frame's own move (exposureMove, the
|
||||
// shared TONE_MATH_SKSL): a stop on LIGHT, decided in the linear domain. The pass
|
||||
// this replaced multiplied the three channels in linear light, so a channel that
|
||||
// reached the ceiling clipped by a different amount than its neighbours and the
|
||||
// hue went with it — 29.2° on a warm skin tone at +1 EV, 33.3° at +2 (scratchpad
|
||||
// exp-variant.mjs), while a mid-grey is the same number both ways (0.6858) and
|
||||
// cannot tell the two apart.
|
||||
const hue = (p) => {
|
||||
const [r, g, b] = p.map((v) => v / 255);
|
||||
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
|
||||
if (d < 1e-9) return NaN;
|
||||
const h = mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||
return ((h * 60) % 360 + 360) % 360;
|
||||
};
|
||||
const up = render([knob({ exposure: 1 })], false);
|
||||
assert.ok(Math.abs(up[0] - 175) <= 2, `+1 EV is not a stop on light: 128 came out ${up[0]}, not 175`);
|
||||
const down = render([knob({ exposure: -1 })], false);
|
||||
assert.ok(Math.abs(down[0] - 93) <= 2, `-1 EV is not a stop on light: 128 came out ${down[0]}, not 93`);
|
||||
const warm8 = [230, 150, 50];
|
||||
const lit = render([knob({ exposure: 1 })], false, warm8);
|
||||
assert.ok(lit.every((v, i) => v >= warm8[i]), `+1 EV darkened a channel: ${lit}`);
|
||||
assert.ok(Math.abs(hue(lit) - hue(warm8)) < 3, `the exposure moved the hue to ${hue(lit)}° from ${hue(warm8)}°`);
|
||||
// A pixel already against the ceiling gives up saturation rather than hue, and
|
||||
// the channel on the ceiling lands ON it: 255 out, not three clipped at three
|
||||
// different points.
|
||||
const hot = render([knob({ exposure: 2 })], false, warm8);
|
||||
assert.ok(Math.abs(hue(hot) - hue(warm8)) < 3, `the exposure moved the hue at the ceiling: ${hue(hot)}°`);
|
||||
assert.equal(hot[0], 255, 'the channel against the ceiling did not land on it');
|
||||
assert.ok(hot[1] > lit[1] && hot[2] > lit[2], `+2 EV did not brighten past +1 EV: ${lit} then ${hot}`);
|
||||
|
||||
console.log(
|
||||
'mask WB: neutral 5500K/0 leaves the pixel, 10000K warms it, +TINT magenta-ises it; the block, the plain and the spatial shaders all read it',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user