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:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user