Keep the highlight slider off the greys, and start each mode clean

The highlight knee opened at 0.45, so dropping HIGHLIGHT pulled a mid-grey
down with the true highlights. It now opens at 0.65 (and the coefficient
drops 0.32 -> 0.22 to stay monotonic), so the slider leaves everything up to
a light grey untouched and still rolls the bright end off. Shadow is
untouched — its 0.00..0.55 knee was already right.

Switching between the library and the camera now drops the edits back to
the recipe's own values, no frame and no custom mark. A frame was picked
for the still it sat on and the mark was placed against that very photo, so
neither may follow the user across the switch (or into a live capture),
exactly like the sliders that were tuned on the photo in front of them.
This commit is contained in:
2026-09-10 12:23:56 +07:00
parent bba2d76957
commit e529c608f0
4 changed files with 45 additions and 20 deletions
+34 -10
View File
@@ -402,6 +402,32 @@ export default function App() {
// never flip the watermark on/off.
};
// The recipe exactly as the user picked it. `selectedRecipe` is NOT a safe
// copy of it: every slider move rewrites its `adjustments` in place
// (handleUpdateAdjustments), so the canonical values only survive in the
// film-sim table and in the saved list.
const pristineRecipe = (recipe: Recipe): Recipe | null => {
const sim = FILM_SIMS.find((f) => f.id === recipe.id);
if (sim) return filmSimToRecipe(sim);
return recipes.find((r) => r.id === recipe.id) || null;
};
// Every edit belongs to the mode and the photo it was made on, so it never
// crosses into the next session: the sliders go back to the recipe's own
// values, a frame picked for that still is dropped, and the custom mark —
// placed against that very photo — is switched back off.
const resetEditsToRecipe = () => {
const base = selectedRecipe ? pristineRecipe(selectedRecipe) : null;
if (base) {
applyRecipe(base);
} else {
setAdjustments({ ...DEFAULT_ADJUSTMENTS });
setSelectedFrame('none');
}
setCustomWm({ ...DEFAULT_CUSTOM_WM });
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
};
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
// Storm guard, generic across every param: same-value writes (slider
// micro-jitter / repeated pointer-move events) still build a fresh
@@ -595,6 +621,10 @@ export default function App() {
setLibraryImageUri(asset.uri);
setPhotoRotation(0);
setMode('library');
// Picking from the camera is the other half of that switch (the camera
// button is the way back), so the camera's edits must not land on the
// photo: the picked still starts at the recipe's own values too.
if (mode !== 'library') resetEditsToRecipe();
// Clear old GPS — will be replaced if the photo (or device) has location data
setGpsInfo(null);
@@ -854,17 +884,11 @@ export default function App() {
// placement layer left eating the camera's tap-to-focus.
setOpenTab(null);
setOpenParamKey(null);
// A frame was picked for the still it sits on; camera mode has no FRAME
// picker to undo it, so it must never follow the user onto the live camera
// (and into the capture, which burns the frame in).
resetEditsToRecipe();
// Camera mode has no FRAME picker to undo a frame, so even a recipe that
// carries one must not follow the user onto the live camera (and into the
// capture, which burns the frame in).
if (next === 'camera') setSelectedFrame('none');
// Same for the custom watermark: it was placed against THAT library photo,
// so it must not follow the user onto the live camera either — the mark
// goes back to off, and re-enabling + editing it is a deliberate choice.
if (next === 'camera') {
setCustomWm({ ...DEFAULT_CUSTOM_WM });
setCustomWmApplied({ ...DEFAULT_CUSTOM_WM });
}
setMode(next);
};
@@ -485,11 +485,11 @@ 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)
// Wide, soft knees (see TONE_SKSL): HL rides the upper half, SH the lower.
val hlMask = smoothstep(0.45f, 1.00f, t)
// Wide, soft knees (see TONE_SKSL): HL rides the top, SH the lower half.
val hlMask = smoothstep(0.65f, 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
var o = t + hl * 0.22f * 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
+1 -1
View File
@@ -53,7 +53,7 @@ export async function nativeBenchProbe(): Promise<void> {
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 -> 173; gray64 sh=+1 -> 113; white dr=1 -> 209;
// white(255) hl=-1 -> 199; 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' },
+7 -6
View File
@@ -9,14 +9,15 @@ import { ColorAdjustments } from '../types';
// turned saturated blues black under -SH and bright colours grey under -HL.
//
// 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
// at the very ends: HL rides only the top (0.65..1.00) so it leaves the greys
// and midtones alone (a 0.45 knee dragged a mid-grey down) while SH rides the
// lower half (0.00..0.55), and the 0.50 midpoint never moves. 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
// monotonic (steepest knee slope 4.29/unit * 0.22 = 0.94, and the two knees
// never overlap), 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.
//
@@ -38,10 +39,10 @@ vec4 main(vec2 xy) {
vec4 c = src.eval(xy);
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 hlMask = smoothstep(0.65, 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 + hl * 0.32 * hlMask + sh * 0.34 * shMask;
float o = t + hl * 0.22 * 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;