Files
RecipesCam/src/utils/toneShader.ts
T
admin 65d0850317 FX vignetting, LIGHT vibrance/saturation, reticle unlock
- FX: new VIGNETTING 0-10 control (chip + slider). Darkens the four
  corners of the photo with a radial gradient whose radius is the photo
  diagonal/2, so the falloff stays circular on any aspect. Applied in
  preview (camera overlay, polaroid/wall window, plain library) and in
  exportEngine pass 6b, always on the picture, never on the mat.
- LIGHT: new VIBRANCE -10..+10 (chroma-masked saturation in the tone
  shader: vivid pixels move least). Renamed the existing saturation
  control chip from COLOR to SATURATION - same param, clearer label.
- Fix: <Shader> takes a RuntimeEffect, not a gradient SkShader; the
  preview vignette threw "Object is not a HostObject" when it got one.
  Use the declarative <RadialGradient> child on the Rect instead.
- Preview: drop the "AE/AF LOCKED - TAP TO UNLOCK" banner; the lock
  reticle is now itself the unlock touch target.
2026-09-10 10:22:55 +07:00

89 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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).
//
// 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.
//
// 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.)
//
// dr - DR strength 0..1: lifts shadows slightly and rolls highlights
// (Fuji extended DR); 0/auto/DR100 = no extra curve.
// hl - highlight -1..1: + lifts toward white, - rolls the bright side down.
// sh - shadow -1..1: + lifts the dark side, - deepens it.
// vib - vibrance -1..1: chroma-masked saturation. It rides along in this shader
// (rather than the colour matrix) because it needs per-pixel chroma:
// already-vivid pixels move least, so skins/skies deepen without the neon
// clip a plain Saturation boost causes. All-zero still = no pass.
export const TONE_SKSL = `
uniform shader src;
uniform float dr;
uniform float hl;
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);
// 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;
}
// 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);
// 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);
float mn = min(min(rgb.r, rgb.g), rgb.b);
float chroma = mx > 0.0001 ? (mx - mn) / mx : 0.0;
float kv = 1.0 + vib * 0.75 * (1.0 - chroma);
return vec4(clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0), c.a);
}
`;
export interface ToneUniforms {
// All zero → no tone adjustment needed (caller can skip the shader pass).
dr: number; // 0..1
hl: number; // -1..1 (adjustments.highlight / 10)
sh: number; // -1..1 (adjustments.shadow / 10)
vib: number; // -1..1 (adjustments.vibrance / 10)
}
export function getToneUniforms(adj: ColorAdjustments): ToneUniforms {
const drRaw = adj.dynamicRange ?? 'auto';
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
const sh = Math.max(-1, Math.min(1, (adj.shadow ?? 0) / 10));
const vib = Math.max(-1, Math.min(1, (adj.vibrance ?? 0) / 10));
return { dr, hl, sh, vib };
}
export function toneIsActive(u: ToneUniforms): boolean {
return u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || u.vib !== 0;
}