import type { ToneCurve, ToneCurveChannel, ToneCurvePoint } from '../types'; // LIGHT tab > TONE CURVE — the graph on the photo, and the table the renderer // reads it as. // // One curve per channel, each a list of control points. The graph hands the // renderer 256 numbers per channel rather than the points themselves: a point // list would have to be interpolated per pixel inside SkSL, and SkSL indexes // uniform arrays by constant only (see TONE_SKSL's unrolled mixer), so a table // looked up in a texture is both the cheaper and the only workable shape. // // The interpolation is a monotone cubic (Fritsch–Carlson). A plain cubic spline // overshoots between two close points — the classic tone-curve tell, a bright // halo next to a lifted shadow — and Lightroom's curves do not do it: drag a // point and the line bends through it without ever turning back on itself. const EPS = 1e-6; const FLAT = 0.002; // how far off the diagonal a point must sit to count as a move export const CURVE_CHANNELS: ToneCurveChannel[] = ['rgb', 'r', 'g', 'b']; export const CURVE_LUT_SIZE = 256; // What each channel's line is drawn in, and what it does to the pixels. export const CURVE_CHANNEL_COLOR: Record = { rgb: '#f2f2f2', r: '#ff5a52', g: '#4fd06a', b: '#4f8dff', }; export const IDENTITY_CURVE: ToneCurvePoint[] = [ [0, 0], [1, 1], ]; // The closest two points may sit on the input axis — nearer than this and they // would drag each other around instead of moving one bend. export const CURVE_MIN_GAP = 0.02; const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); const num = (v: unknown, fallback: number) => { const n = Number(v); return Number.isFinite(n) ? n : fallback; }; // The stored points, made readable: numbers, inside the square, ordered along // the input axis, and pinned to the two ends. Everything below reads a curve // through this, so a hand-written recipe file cannot produce a curve the graph // and the renderer disagree about. export function curvePoints(curve: ToneCurve | undefined, ch: ToneCurveChannel): ToneCurvePoint[] { const raw = curve?.[ch]; if (!Array.isArray(raw) || raw.length < 2) return IDENTITY_CURVE.map((p) => [...p] as ToneCurvePoint); const pts = raw .map((p) => [clamp01(num(p?.[0], 0)), clamp01(num(p?.[1], 0))] as ToneCurvePoint) .sort((a, b) => a[0] - b[0]); pts[0][0] = 0; pts[pts.length - 1][0] = 1; return pts; } // True when the curve would move a pixel — the chip's amber dot, and whether the // renderer bothers with the pass at all. A point off the diagonal is what makes // a curve non-flat: a monotone cubic through points all ON the diagonal is the // diagonal. export function curveIsActive(curve: ToneCurve | undefined): boolean { if (!curve) return false; return CURVE_CHANNELS.some((ch) => curvePoints(curve, ch).some((p) => Math.abs(p[1] - p[0]) > FLAT)); } // Fritsch–Carlson tangents: the slopes that keep the interpolant monotone // between the points, and vanish at a flat segment instead of bulging past it. function tangents(pts: ToneCurvePoint[]): number[] { const n = pts.length; const d: number[] = []; for (let i = 0; i < n - 1; i++) d.push((pts[i + 1][1] - pts[i][1]) / Math.max(EPS, pts[i + 1][0] - pts[i][0])); const m: number[] = new Array(n).fill(0); m[0] = d[0]; m[n - 1] = d[n - 2]; for (let i = 1; i < n - 1; i++) m[i] = (d[i - 1] + d[i]) / 2; for (let i = 0; i < n - 1; i++) { if (Math.abs(d[i]) < EPS) { m[i] = 0; m[i + 1] = 0; continue; } const a = m[i] / d[i]; const b = m[i + 1] / d[i]; const s = a * a + b * b; if (s > 9) { const t = 3 / Math.sqrt(s); m[i] = t * a * d[i]; m[i + 1] = t * b * d[i]; } } return m; } // The curve's output for one input, clamped into the square: the graph has no // room above white or below black, so neither has the curve. export function sampleCurve(pts: ToneCurvePoint[], x: number): number { const p = Array.isArray(pts) && pts.length >= 2 ? pts : IDENTITY_CURVE; const m = tangents(p); const t = clamp01(x); if (t <= p[0][0]) return clamp01(p[0][1]); for (let i = 0; i < p.length - 1; i++) { if (t <= p[i + 1][0]) { const h = Math.max(EPS, p[i + 1][0] - p[i][0]); const s = (t - p[i][0]) / h; const s2 = s * s; const s3 = s2 * s; const y = (2 * s3 - 3 * s2 + 1) * p[i][1] + (s3 - 2 * s2 + s) * h * m[i] + (-2 * s3 + 3 * s2) * p[i + 1][1] + (s3 - s2) * h * m[i + 1]; return clamp01(y); } } return clamp01(p[p.length - 1][1]); } // The curve as the renderer reads it: 256 entries per output channel, in one // RGBA row — R, G, B are the three channels' own curves and A is unused (kept at // 255 so the image is unambiguous). Each channel's curve runs first and the // composite after it, so a point moved on R means "R, as the composite curve // then leaves it". export function curveLut(curve: ToneCurve | undefined): Uint8Array { const master = curvePoints(curve, 'rgb'); const chans = (['r', 'g', 'b'] as const).map((ch) => curvePoints(curve, ch)); const out = new Uint8Array(CURVE_LUT_SIZE * 4); for (let i = 0; i < CURVE_LUT_SIZE; i++) { const v = i / (CURVE_LUT_SIZE - 1); for (let c = 0; c < 3; c++) out[i * 4 + c] = Math.round(sampleCurve(master, sampleCurve(chans[c], v)) * 255); out[i * 4 + 3] = 255; } return out; } // The curve pass: the graded pixels in, the table's answer out. The LUT is // sampled at texel centres (`v * 255 + 0.5`), which is where the 256 values // actually sit, so entry i returns entry i and the ends clamp to the end values. export const CURVE_SKSL = ` uniform shader src; uniform shader lut; vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 v = clamp(c.rgb, 0.0, 1.0) * 255.0 + 0.5; return vec4( lut.eval(vec2(v.r, 0.5)).r, lut.eval(vec2(v.g, 0.5)).g, lut.eval(vec2(v.b, 0.5)).b, c.a ); } `; // --- what the graph itself does to a point list ---------------------------- // Kept here rather than in the panel: the rules (which point may move where, // what a click adds, what survives a delete) are about the curve, not the SVG. export function addCurvePoint(pts: ToneCurvePoint[], x: number, y: number): ToneCurvePoint[] { const px = clamp01(x); const py = clamp01(y); let at = pts.length; for (let i = 0; i < pts.length; i++) { if (pts[i][0] >= px) { at = i; break; } } const before = at > 0 ? pts[at - 1] : null; const after = at < pts.length ? pts[at] : null; if (before && px - before[0] < CURVE_MIN_GAP) return pts; if (after && after[0] - px < CURVE_MIN_GAP) return pts; // A click adds the point ON the line under it, not where the pointer was: the // promise is "grab the curve here", and a click that moved the picture by // itself would make the graph dangerous. A click that does not lift is a point // added and left on the line — no pixel moves until one is dragged. return [...pts.slice(0, at), [px, sampleCurve(pts, px)] as ToneCurvePoint, ...pts.slice(at)]; } // `at` is the point's index in the list; the two ends keep their place on the // input axis (they are black and white) and slide freely on the output axis. export function moveCurvePoint(pts: ToneCurvePoint[], at: number, x: number, y: number): ToneCurvePoint[] { if (at < 0 || at >= pts.length) return pts; const next = pts.map((p) => [...p] as ToneCurvePoint); const first = at === 0; const last = at === pts.length - 1; const lo = first ? 0 : Math.min(pts[at - 1][0] + CURVE_MIN_GAP, 1 - CURVE_MIN_GAP); const hi = last ? 1 : Math.max(pts[at + 1][0] - CURVE_MIN_GAP, CURVE_MIN_GAP); next[at][0] = first ? 0 : last ? 1 : Math.max(lo, Math.min(hi, clamp01(x))); next[at][1] = clamp01(y); return next; } export function removeCurvePoint(pts: ToneCurvePoint[], at: number): ToneCurvePoint[] { if (at <= 0 || at >= pts.length - 1) return pts; const next = pts.filter((_, i) => i !== at); return next.length >= 2 ? next : IDENTITY_CURVE.map((p) => [...p] as ToneCurvePoint); } // Where a curve sits at the ends, for a caller that wants to say "R: +12 at the // top" — the panel prints nothing today, but the reset button and the chip need // the same "is it flat" answer. export function isFlatCurve(pts: ToneCurvePoint[]): boolean { return pts.every((p) => Math.abs(p[1] - p[0]) <= FLAT); }