light: AUTO and the shipped recipes ride the halved knobs too — AUTO's ceiling doubles with them so a blown frame still comes back the way it did, and each recipe's HIGHLIGHT/SHADOW doubles so its look stays on the knot it was tuned to

This commit is contained in:
2026-09-30 18:10:51 +07:00
parent a56581c757
commit 25b1312e0a
3 changed files with 40 additions and 28 deletions
+10 -10
View File
@@ -62,28 +62,28 @@ assert.equal(Math.round(stops(spike(87)) * 10) / 10, 0.5, 'a dark frame is asked
assert.ok(Math.abs(stops(flat()) + 0.0589) < 5e-4, 'a flat ramp sits just under mid-grey');
assert.equal(stops(spike(20, 0)), AUTO_EV_MAX, 'an empty histogram takes the ceiling, not infinity');
// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±5.
assert.deepEqual(autoTone(spike(255)), { highlight: -5, shadow: 0 }, 'blown frame only pulls highlights');
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 5 }, 'black frame only opens shadows');
// --- Highlight / Shadow: the 1% tails, thresholds 0.9 / 0.02, ramp to ±10.
assert.deepEqual(autoTone(spike(255)), { highlight: -10, shadow: 0 }, 'blown frame only pulls highlights');
assert.deepEqual(autoTone(spike(0)), { highlight: 0, shadow: 10 }, 'black frame only opens shadows');
// The flat ramp is the interesting one: its own top 1% really is clipped and its
// own bottom 1% really is on the floor, so both ends move. p99 = 253/255 = 0.992
// (5 units), p01 = 2/255 = 0.008 (3 units).
assert.deepEqual(autoTone(flat()), { highlight: -5, shadow: 3 }, 'an even ramp has both ends in play');
// (9 units), p01 = 2/255 = 0.008 (6 units).
assert.deepEqual(autoTone(flat()), { highlight: -9, shadow: 6 }, 'an even ramp has both ends in play');
// 1% of the frame at white over an otherwise mid ramp: the spec's small blown
// window, and the shadow side stays put because the ramp's floor is not black.
const blown = flat();
blown[255] += 3;
assert.deepEqual(autoTone(blown), { highlight: -5, shadow: 3 }, 'a blown 1% is enough to pull the top');
assert.deepEqual(autoTone(blown), { highlight: -10, shadow: 6 }, 'a blown 1% is enough to pull the top');
// p99 landed just over the threshold: 989 pixels mid-ramp and 11 at bin 243 puts
// p99 at 0.953, 0.053 over 0.9 -> 2.65 units -> 3. Proof the pull is graded.
// p99 at 0.953, 0.053 over 0.9 -> 5.3 units -> 5. Proof the pull is graded.
const shoulder = spike(100, 989);
shoulder[243] = 11;
assert.deepEqual(autoTone(shoulder), { highlight: -3, shadow: 0 }, 'p99 just over 0.9 pulls a third of the way');
assert.deepEqual(autoTone(shoulder), { highlight: -5, shadow: 0 }, 'p99 just over 0.9 pulls half the way');
// 2% of pixels two bins off the floor: p01 = 0.008, so the shadow side moves —
// but only 3 units, because the tail is barely under the 0.02 threshold.
// but only 6 units, because the tail is barely under the 0.02 threshold.
const crushed = spike(128, 980);
crushed[2] = 20;
assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 3 }, 'a crushed 2% opens the shadows part way');
assert.deepEqual(autoTone(crushed), { highlight: 0, shadow: 6 }, 'a crushed 2% opens the shadows part way');
// 0.4% on the floor is NOT the bottom 1%: the tenth-darkest pixel is the mid
// ramp, so the threshold is not crossed and the knob stays put.
const speck = spike(128, 996);
+20 -14
View File
@@ -95,6 +95,12 @@ export function filmSimToRecipe(sim: FilmSim): Recipe {
};
}
// The shipped recipes. HIGHLIGHT and SHADOW here are written in the KNOB's unit,
// and both knobs ride HALF an anchor per unit (see a1/a3 in toneShader.ts), so a
// recipe that wants a given roll-off asks for double what the knot move reads —
// the same note FILM_TONE carries. The look is the knot, not the number, which is
// why these were doubled when the two knobs' reach was halved: a recipe that had
// its highlights rolled to 0.70 still rolls them there.
export const DEFAULT_RECIPES: Recipe[] = [
{
id: 'classic-neg-default',
@@ -106,8 +112,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: -1,
temperature: 6300,
tint: 2,
highlight: -2,
shadow: 1,
highlight: -4,
shadow: 2,
denoise: 1,
clarity: 2,
grain: 4,
@@ -130,8 +136,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 3,
temperature: 5500,
tint: 0,
highlight: 1,
shadow: -1,
highlight: 2,
shadow: -2,
denoise: 2,
clarity: 4,
grain: 1,
@@ -208,8 +214,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 1,
temperature: 7500,
tint: -2,
highlight: -3,
shadow: 2,
highlight: -6,
shadow: 4,
denoise: 3,
clarity: -2,
grain: 5,
@@ -233,8 +239,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 1,
temperature: 5800,
tint: 0,
highlight: -1,
shadow: 1,
highlight: -2,
shadow: 2,
denoise: 2,
clarity: 1,
grain: 3,
@@ -258,8 +264,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 2,
temperature: 6000,
tint: -1,
highlight: 1,
shadow: -1,
highlight: 2,
shadow: -2,
denoise: 1,
clarity: 2,
grain: 2,
@@ -283,8 +289,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: 2,
temperature: 7200,
tint: -2,
highlight: -2,
shadow: 1,
highlight: -4,
shadow: 2,
denoise: 2,
clarity: 0,
grain: 4,
@@ -308,8 +314,8 @@ export const DEFAULT_RECIPES: Recipe[] = [
saturation: -2,
temperature: 4500,
tint: 2,
highlight: -2,
shadow: 2,
highlight: -4,
shadow: 4,
denoise: 3,
clarity: -2,
grain: 3,
+10 -4
View File
@@ -81,10 +81,16 @@ export function autoExposureStops(lum: number[]): number {
return Math.max(-AUTO_EV_MAX, Math.min(AUTO_EV_MAX, stops));
}
// How far AUTO may push HIGHLIGHT and SHADOW, in the sliders' own units: half
// of the ±10 ruler, so the frame is corrected but a hand can still finish the
// move. The knob then reports the number AUTO chose, the way the EV knob does.
const AUTO_TONE_MAX = 5;
// How far AUTO may push HIGHLIGHT and SHADOW, in the sliders' own units: the
// whole ±10 ruler, so a frame that really is clipped (or really is crushed)
// really does come back. It was 5 when a unit of HIGHLIGHT or SHADOW moved its
// knot a whole anchor; both now ride HALF an anchor (see a1/a3 in toneShader.ts),
// so 5 would have halved AUTO's own correction with the knob's reach. At 10 it
// draws the knot as far as the old number did — HIGHLIGHT to 0.625, SHADOW to
// 0.375 — where the quarter beside it is still 0.5 of its own slope and never
// flat — and the knob then reports the number AUTO chose, the way the EV knob
// does.
const AUTO_TONE_MAX = 10;
// The value `p` of the way up the binned ramp (0.99 for the top 1% of pixels):
// walk the cumulative count to the first bin that passes `p * total`, and report