diff --git a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt index 85b650b..f6f6076 100644 --- a/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt +++ b/modules/recipescam-export/android/src/main/java/com/locphamtran/recipescamera/export/RecipescamExportModule.kt @@ -485,29 +485,22 @@ private fun applyTone(bmp: Bitmap, dr: Float, hl: Float, sh: Float) { val g = ((c shr 8) and 0xFF) / 255f val b = (c and 0xFF) / 255f val t = (0.2126f * r + 0.7152f * g + 0.0722f * b).coerceIn(0f, 1f) - val hlMask = smoothstep(0.80f, 1.00f, t) - val shMask = 1f - smoothstep(0.00f, 0.30f, t) - var o = t - // Highlight: bright end only, multiplicative on both signs (symmetric region). - if (hl >= 0f) { - o *= 1f + hl * 0.30f * hlMask - } else { - o *= 1f + hl * 0.45f * hlMask - } - // Shadow: dark end only. - if (sh >= 0f) { - o += sh * 0.80f * shMask * (1f - t) - } else { - o *= 1f + sh * 0.80f * shMask - } + // Wide, soft knees (see TONE_SKSL): HL rides the upper half, SH the lower. + val hlMask = smoothstep(0.45f, 1.00f, t) + val shMask = 1f - smoothstep(0.00f, 0.55f, t) + // Pure additive luma shifts -> monotonic curve (no reversal on a ramp). + var o = t + hl * 0.32f * hlMask + sh * 0.34f * shMask // Dynamic range: gentle shadow lift + highlight roll (protect brights). o += dr * 0.12f * shMask * (1f - t) o -= dr * 0.18f * hlMask * t - val f = (o / max(t, 0.0004f)).coerceIn(0f, 4f) + o = o.coerceIn(0f, 1f) + // Lightness takes the curve, hue stays: the colour difference is gained + // part-way so darkening cannot collapse a saturated colour to black. + val cg = (o / max(t, 0.0004f)).coerceIn(0.55f, 1.35f) val alpha = (c ushr 24) and 0xFF - val nr = ((r * f).coerceIn(0f, 1f) * 255f).roundToInt() - val ng = ((g * f).coerceIn(0f, 1f) * 255f).roundToInt() - val nb = ((b * f).coerceIn(0f, 1f) * 255f).roundToInt() + val nr = ((o + (r - t) * cg).coerceIn(0f, 1f) * 255f).roundToInt() + val ng = ((o + (g - t) * cg).coerceIn(0f, 1f) * 255f).roundToInt() + val nb = ((o + (b - t) * cg).coerceIn(0f, 1f) * 255f).roundToInt() px[i] = (alpha shl 24) or (nr shl 16) or (ng shl 8) or nb } bmp.setPixels(px, 0, w, 0, 0, w, h) diff --git a/src/dev/nativeBenchProbe.ts b/src/dev/nativeBenchProbe.ts index 8b06149..da080c6 100644 --- a/src/dev/nativeBenchProbe.ts +++ b/src/dev/nativeBenchProbe.ts @@ -53,8 +53,8 @@ export async function nativeBenchProbe(): Promise { console.error('[BENCH] classic-neg-on-black matrix[0..4]=' + matrix.slice(0, 5).join(',')); // P2 tone parity on solid ramps (expected values computed from TONE_SKSL): - // white(255) hl=-1 -> 140; gray64 sh=+1 -> 75; white dr=1 -> 209; - // gray26 sh=+1 -> 104; gray26 hl=+1 sh=-1 -> 11. + // white(255) hl=-1 -> 173; gray64 sh=+1 -> 113; white dr=1 -> 209; + // gray26 sh=+1 -> 105; gray26 hl=+1 sh=-1 -> 0. const toneGates: Array<{ rgb: [number, number, number]; tone: { dr: number; hl: number; sh: number }; tag: string }> = [ { rgb: [255, 255, 255], tone: { dr: 0, hl: -1, sh: 0 }, tag: 'white-hl-1' }, { rgb: [64, 64, 64], tone: { dr: 0, hl: 0, sh: 1 }, tag: 'gray64-sh+1' }, diff --git a/src/utils/toneShader.ts b/src/utils/toneShader.ts index a6e928b..290d995 100644 --- a/src/utils/toneShader.ts +++ b/src/utils/toneShader.ts @@ -1,22 +1,24 @@ import { ColorAdjustments } from '../types'; // Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow). -// SkSL runtime effect over a child image shader, driven by luma so hue is -// preserved (single multiplicative factor per pixel). +// SkSL runtime effect over a child image shader. // -// Soft-knee masks keep the knobs independent, like camera tone curves: -// hl affects ONLY the bright end (knee 0.80..1.00), -// sh affects ONLY the dark end (knee 0.00..0.30), -// leaving the whole 0.30..0.80 mid/upper band untouched. Earlier knees -// (0.40..0.95 / 0.05..0.55, then 0.55..1.0 / 0.0..0.45, then 0.70..1.0) still -// reached mid/upper grays, so HL read as a global gray/brightness change -// instead of isolating the bright end — on-device testing confirmed it. +// 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. // -// HL is multiplicative on BOTH signs so +/− act on the same region: -// + scales 0.85+ up toward white (natural clip at 1.0), − scales them down. -// A + additive lift using (1-t) dies at t→1.0 and never touched the specular -// end — that asymmetry made +HL look like a mid-tone push, not a highlight -// change. (SH keeps its original form; additive + worked there.) +// Wide, soft knees so the knobs reach like a tone curve instead of biting only +// at the very ends: HL rides the upper half (0.45..1.00), SH the lower half +// (0.00..0.55), and the 0.50 midpoint moves less than 3%. Earlier narrow +// knees (0.80..1.00 / 0.00..0.30) made both sliders look dead on any photo +// without true whites or blacks. +// +// Both knobs are pure additive shifts of the luma. That keeps the curve +// monotonic (steepest knee slope 2.73/unit * 0.32 = 0.87, +SH overlap still +// positive), 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. // // dr - DR strength 0..1: lifts shadows slightly and rolls highlights // (Fuji extended DR); 0/auto/DR100 = no extra curve. @@ -34,28 +36,21 @@ uniform float sh; uniform float vib; vec4 main(vec2 xy) { vec4 c = src.eval(xy); - float t = clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); - float hlMask = smoothstep(0.80, 1.0, t); - float shMask = 1.0 - smoothstep(0.0, 0.30, t); + vec3 rgb = clamp(c.rgb, 0.0, 1.0); + float t = clamp(dot(rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0); + float hlMask = smoothstep(0.45, 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. - float o = t; - // Highlight: bright end only, multiplicative on both signs (symmetric region). - if (hl >= 0.0) { - o *= 1.0 + hl * 0.30 * hlMask; - } else { - o *= 1.0 + hl * 0.45 * hlMask; - } - // Shadow: dark end only. - if (sh >= 0.0) { - o += sh * 0.80 * shMask * (1.0 - t); - } else { - o *= 1.0 + sh * 0.80 * shMask; - } + float o = t + hl * 0.32 * hlMask + 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 f = clamp(o / max(t, 0.0004), 0.0, 4.0); - vec3 rgb = clamp(c.rgb * f, 0.0, 1.0); + 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); // Vibrance: push the LESS-saturated pixels harder than the vivid ones. float l2 = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); float mx = max(max(rgb.r, rgb.g), rgb.b);