From 25b1312e0afbca58a7fe125cf97395aa782d3c61 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 30 Sep 2026 18:10:51 +0700 Subject: [PATCH] =?UTF-8?q?light:=20AUTO=20and=20the=20shipped=20recipes?= =?UTF-8?q?=20ride=20the=20halved=20knobs=20too=20=E2=80=94=20AUTO's=20cei?= =?UTF-8?q?ling=20doubles=20with=20them=20so=20a=20blown=20frame=20still?= =?UTF-8?q?=20comes=20back=20the=20way=20it=20did,=20and=20each=20recipe's?= =?UTF-8?q?=20HIGHLIGHT/SHADOW=20doubles=20so=20its=20look=20stays=20on=20?= =?UTF-8?q?the=20knot=20it=20was=20tuned=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/frontend/scripts/auto-tone-check.mjs | 20 +++++------ .../frontend/shared/utils/defaultRecipes.ts | 34 +++++++++++-------- docker/frontend/src/ui/Histogram.tsx | 14 +++++--- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/docker/frontend/scripts/auto-tone-check.mjs b/docker/frontend/scripts/auto-tone-check.mjs index 7254b93..569b34a 100644 --- a/docker/frontend/scripts/auto-tone-check.mjs +++ b/docker/frontend/scripts/auto-tone-check.mjs @@ -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); diff --git a/docker/frontend/shared/utils/defaultRecipes.ts b/docker/frontend/shared/utils/defaultRecipes.ts index ab3d58f..b221763 100644 --- a/docker/frontend/shared/utils/defaultRecipes.ts +++ b/docker/frontend/shared/utils/defaultRecipes.ts @@ -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, diff --git a/docker/frontend/src/ui/Histogram.tsx b/docker/frontend/src/ui/Histogram.tsx index bce5d29..e939e7c 100644 --- a/docker/frontend/src/ui/Histogram.tsx +++ b/docker/frontend/src/ui/Histogram.tsx @@ -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