diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index eb3b912..0f2a465 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -84,6 +84,19 @@ export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' // stays empty until the mixer is actually used. export type HslBand = [number, number, number]; +// LIGHT tab > TONE CURVE. One editable curve per channel, each a list of +// control points as [input, output] pairs in 0..1 — the first pinned to x=0 and +// the last to x=1, so every curve answers for black and for white. The composite +// ('rgb') curve is applied AFTER the three channels (Photoshop's order), which is +// what makes the same points print the same numbers in every renderer. +// +// Only the channels the user actually moved are stored, so an untouched curve +// costs nothing, an old recipe stays valid, and the recipe JSON the API keeps +// carries it without a migration. +export type ToneCurvePoint = [number, number]; +export type ToneCurveChannel = 'rgb' | 'r' | 'g' | 'b'; +export type ToneCurve = Partial>; + export interface ColorAdjustments { exposure: number; // -10 to +10 (mapped to matrix multiplier or offset) contrast: number; // -10 to +10 @@ -116,6 +129,9 @@ export interface ColorAdjustments { hslHue?: number; hslSat?: number; hslLum?: number; + // TONE CURVE, the last word on tone: applied after everything above. Absent or + // flat = no move at all. + toneCurve?: ToneCurve; exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain. } diff --git a/docker/frontend/shared/utils/toneCurve.ts b/docker/frontend/shared/utils/toneCurve.ts new file mode 100644 index 0000000..3f075b5 --- /dev/null +++ b/docker/frontend/shared/utils/toneCurve.ts @@ -0,0 +1,203 @@ +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); +} diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index a45b93b..0151fef 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { ToolRail, type TabId } from './ui/ToolRail'; import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn'; import { ImageStage } from './ui/ImageStage'; import { StraightenRuler } from './ui/StraightenRuler'; +import { ToneCurvePanel } from './ui/ToneCurvePanel'; import { TopBar } from './ui/TopBar'; import { AuthModal } from './ui/AuthModal'; import { NameModal } from './ui/NameModal'; @@ -35,6 +36,7 @@ import { } from '../shared/types'; import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils'; import { grainPerInch, grainStockFor } from '../shared/utils/grainShader'; +import { curveIsActive } from '../shared/utils/toneCurve'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. @@ -1674,8 +1676,9 @@ export function Workspace() { // STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not // stacked in this column at all. Its ruler lies across the foot of the // photo (StraightenRuler, drawn by ImageStage) so the angle is read off the - // picture it is levelling; nothing opens here. - if (openParam === 'straighten') return null; + // picture it is levelling; nothing opens here. TONE CURVE is the same shape + // of tool: its graph is on the photo, so it opens no row either. + if (openParam === 'straighten' || openParam === 'curve') return null; // The mixer's three knobs are not in PARAM_DEFS: they read and write the // image's own move rather than a flat field of adjustments, and unlike the // panel on the photo they are not tied to the band the mixer is aimed at. @@ -2213,7 +2216,21 @@ export function Workspace() { return rows; } case 'light': - return [...paramChips(PARAM_DEFS.iq), groupChip('dr')]; + return [ + ...paramChips(PARAM_DEFS.iq), + groupChip('dr'), + // TONE CURVE is not a row of sliders: it opens the graph on the photo + // (ToneCurvePanel), so the chip toggles that overlay rather than a + // ruler in the last column. It glows amber once the graph is off the + // diagonal, which is the only place the curve is reported. + { + key: 'curve', + label: 'TONE CURVE', + active: openParam === 'curve', + amberValue: curveIsActive(recipe.adjustments.toneCurve), + onClick: () => toggleParam('curve'), + }, + ]; case 'wb': return [ { @@ -2768,6 +2785,22 @@ export function Workspace() { setStraightenOnce(0)} /> ) : null } + curvePanel={ + openParam === 'curve' ? ( + + setAdjustmentOnce({ toneCurve: Object.keys(next).length ? next : undefined }) + } + onClose={() => setOpenParam(null)} + /> + ) : null + } /> diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index aa2a789..362d7ba 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -33,6 +33,7 @@ import { glowUniformArray, } from '../../shared/utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../shared/utils/cinemaShader'; +import { CURVE_SKSL, CURVE_LUT_SIZE, curveIsActive, curveLut } from '../../shared/utils/toneCurve'; import { GRAIN_SKSL, HALATION_SKSL, @@ -138,6 +139,7 @@ let toneEffect: any = null; let cinemaEffect: any = null; let glowEffect: any = null; let halationEffect: any = null; +let curveEffect: any = null; function effects() { if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL); @@ -145,7 +147,8 @@ function effects() { if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL); if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL); if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL); - return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect }; + if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL); + return { sharpenEffect, toneEffect, cinemaEffect, glowEffect, halationEffect, curveEffect }; } // CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on @@ -408,9 +411,16 @@ export async function renderPhoto(input: RenderInput): Promise { canvasKit.MakeImageFromEncoded( data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer) ), + // An image from bytes rather than from a file — the tone curve's 256x1 + // lookup table (see toneCurve.ts). RN Skia takes the factory here as + // `Image.MakeImage(info, bytes, bytesPerRow)`; CanvasKit keeps that one + // top-level and only puts the encoded/lazy factories under `Image.`, so the + // call site has to be translated. CanvasKit's ImageInfo also insists on an + // explicit colorSpace where RN Skia's does not: everything this pipeline + // builds is sRGB, so it is filled in rather than repeated at every call. + MakeImage: (info: any, bytes: Uint8Array, bytesPerRow: number) => + canvasKit.MakeImage({ colorSpace: canvasKit.ColorSpace.SRGB, ...info }, bytes, bytesPerRow), }, Typeface: { MakeFreeTypeFaceFromData: (data: ArrayBuffer) => diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index fabc220..e49fa5d 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -323,6 +323,84 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .crop-handle.tr { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; } .crop-handle.bl { left: 0; bottom: 0; transform: translate(-50%, 50%); cursor: nesw-resize; } .crop-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; } +/* LIGHT's TONE CURVE: the graph hung on the photo. The layer is the photo's own + box and takes no pointer events; the card sits at its bottom-left corner and + takes them all, because every gesture in it is about the curve. */ +.curve-layer { position: absolute; pointer-events: none; } +.curve-panel { + position: absolute; + left: 10px; + bottom: 10px; + pointer-events: auto; + touch-action: none; + width: 240px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); + user-select: none; +} +.curve-head { display: flex; align-items: center; justify-content: space-between; } +.curve-title { font-size: 11px; letter-spacing: 0.1em; color: var(--muted); } +.curve-close { + border: none; + background: none; + color: var(--muted); + font-size: 12px; + line-height: 1; + padding: 2px 4px; + cursor: pointer; +} +.curve-close:hover { color: var(--fg); } +.curve-tabs { display: flex; gap: 4px; align-items: center; } +.curve-tab { + display: flex; + align-items: center; + gap: 5px; + flex: 1 1 0; + justify-content: center; + padding: 3px 0; + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; +} +.curve-tab.on { border-color: var(--accent-line); color: var(--fg); } +/* The dot carries the channel's own colour, so the tab says which line it draws; + it goes amber once that channel has left the diagonal. */ +.curve-dot { width: 7px; height: 7px; border-radius: 999px; } +.curve-tab.moved .curve-dot { box-shadow: 0 0 0 2px var(--accent); } +.curve-reset { + flex: 0 0 auto; + padding: 3px 7px; + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; +} +.curve-reset:hover { color: var(--fg); border-color: var(--accent-line); } +.curve-graph { display: block; background: rgba(0, 0, 0, 0.35); border: 1px solid var(--border); border-radius: 3px; cursor: crosshair; } +.curve-grid { stroke: rgba(255, 255, 255, 0.1); stroke-width: 1; } +.curve-diagonal { stroke: rgba(255, 255, 255, 0.28); stroke-width: 1; stroke-dasharray: 3 3; } +/* The line the active tab draws, and — on a channel tab — the composite curve + under it, so the two moves can be told apart. */ +.curve-line { fill: none; stroke-width: 2; stroke-linecap: round; } +.curve-line.ghost { stroke: rgba(255, 255, 255, 0.35); stroke-width: 1; stroke-dasharray: 4 3; } +.curve-point { fill: var(--bg-elev); stroke-width: 2; cursor: grab; } +.curve-point:active { cursor: grabbing; } + /* FRAME's custom watermark box: drag the body to move, the corner to scale. */ .wm-layer { position: absolute; border-radius: var(--radius-sm); pointer-events: none; } .wm-rect { position: absolute; border: 1px dashed var(--accent); cursor: move; pointer-events: auto; touch-action: none; } diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index 08e903d..51a1b43 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -103,6 +103,7 @@ export function ImageStage({ pickPanelAt = null, marks = [], straightenRuler = null, + curvePanel = null, dims = null, }: { previewUrl: string | null; @@ -149,12 +150,20 @@ export function ImageStage({ // FRAME's STRAIGHTEN scale, laid along the foot of the photo. Drawn here // rather than in the panel column because the angle is read off the picture. straightenRuler?: ReactNode; + // LIGHT's TONE CURVE graph, hung on the photo while that chip is on: the curve + // is read against the picture it is reshaping, so it belongs on the stage + // rather than in a column beside it. It is anchored, not draggable — the graph + // is a fixed square and the photo moves under it. + curvePanel?: ReactNode; // The photo's own pixel size, as the stage stands: the file's shape turned by // the quarter turn and cut by a committed crop, so the number in the row under // the photo is the resolution an export at the photo's own size renders. dims?: { w: number; h: number } | null; }) { const showStraighten = !!straightenRuler; + // The curve graph is a fresh node on every parent render too, and the measure + // effect below only ever asks whether it is there. + const showCurve = !!curvePanel; // `marks` is a fresh array on every parent render, and this one is only ever // read for its length, so the effects below key off that instead. const wmCount = marks.length; @@ -240,7 +249,7 @@ export function ImageStage({ }, []); useEffect(() => { - if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten) { + if (!cropEditing && !compare && !picking && !pickPanelAt && !wmCount && !showStraighten && !showCurve) { setBox(null); return; } @@ -261,7 +270,7 @@ export function ImageStage({ ro?.disconnect(); window.removeEventListener('resize', measure); }; - }, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, measure, previewUrl]); + }, [cropEditing, compare, picking, pickPanelAt, wmCount, showStraighten, showCurve, measure, previewUrl]); const grabPanel = (e: React.PointerEvent) => { e.stopPropagation(); // the photo must not pan under the card @@ -801,6 +810,20 @@ export function ImageStage({ ) : null} + {/* LIGHT's TONE CURVE: the graph hangs on the photo while that chip is + on, so the curve is read against the picture it is moving. It + carries no zoom transform — the graph is a fixed square at the + photo's own corner, and a zoom is a way of looking at the photo + underneath it, not at the curve. */} + {curvePanel && box && !peek ? ( +
+ {curvePanel} +
+ ) : null} {/* FRAME's watermarks: each mark's own box, so the text can be dragged around the photo and scaled from its corner. */} {box && !cropEditing && !picking && !peek && wmBoxes.length ? ( diff --git a/docker/frontend/src/ui/ToneCurvePanel.tsx b/docker/frontend/src/ui/ToneCurvePanel.tsx new file mode 100644 index 0000000..af962ba --- /dev/null +++ b/docker/frontend/src/ui/ToneCurvePanel.tsx @@ -0,0 +1,217 @@ +import { useRef, useState } from 'react'; +import type { ToneCurve, ToneCurveChannel, ToneCurvePoint } from '../../shared/types'; +import { + CURVE_CHANNELS, + CURVE_CHANNEL_COLOR, + addCurvePoint, + curvePoints, + isFlatCurve, + moveCurvePoint, + removeCurvePoint, + sampleCurve, +} from '../../shared/utils/toneCurve'; + +// LIGHT > TONE CURVE: the graph that hangs on the photo. Four curves — the +// composite one and one per channel — drawn over a grid, with the points the +// user drags. It reads and writes nothing but the `toneCurve` field, so the +// render is the only other thing that has to know what a curve is (see +// toneCurve.ts for the table it hands the renderer). +// +// A click on the line drops a point on it, a drag moves one, and a double-click +// drops one — the three gestures Lightroom teaches, so nothing here has to be +// explained in words on the photo. +const SIZE = 224; // the graph's own square, in px +const STEPS = 64; // how finely the line is drawn +const HIT = 11; // px within which the pointer takes hold of a point +const NEEDS = 0.002; // how far off the diagonal counts as a move, as in toneCurve.ts + +const LABEL: Record = { rgb: 'RGB', r: 'R', g: 'G', b: 'B' }; + +const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); +const offDiagonal = (pts: ToneCurvePoint[]) => pts.some((p) => Math.abs(p[1] - p[0]) > NEEDS); + +export function ToneCurvePanel({ + curve, + onChange, + onClose, +}: { + curve: ToneCurve | undefined; + onChange: (next: ToneCurve) => void; + onClose: () => void; +}) { + const [channel, setChannel] = useState('rgb'); + const svgRef = useRef(null); + const drag = useRef<{ id: number; at: number } | null>(null); + + const pts = curvePoints(curve, channel); + const master = curvePoints(curve, 'rgb'); + + // A channel pulled back to the diagonal stores nothing: the recipe stays as + // small as the curve is flat, and an old recipe reads the same either way. + const put = (next: ToneCurvePoint[]): ToneCurve => { + const out: ToneCurve = { ...curve }; + if (isFlatCurve(next)) delete out[channel]; + else out[channel] = next; + return out; + }; + + const at = (e: { clientX: number; clientY: number }) => { + const r = svgRef.current?.getBoundingClientRect(); + if (!r || !(r.width > 0) || !(r.height > 0)) return null; + return { x: clamp01((e.clientX - r.left) / r.width), y: clamp01(1 - (e.clientY - r.top) / r.height) }; + }; + + const line = (p: ToneCurvePoint[]) => { + const out: string[] = []; + for (let i = 0; i <= STEPS; i++) { + const x = i / STEPS; + out.push(`${i ? 'L' : 'M'}${(x * SIZE).toFixed(2)} ${((1 - sampleCurve(p, x)) * SIZE).toFixed(2)}`); + } + return out.join(' '); + }; + + const down = (e: React.PointerEvent) => { + if (e.button !== 0) return; + e.stopPropagation(); // the photo must not pan under the graph + const p = at(e); + if (!p) return; + svgRef.current?.setPointerCapture(e.pointerId); + let near = -1; + let best = HIT / SIZE; + pts.forEach((q, i) => { + const d = Math.hypot(q[0] - p.x, q[1] - p.y); + if (d < best) { + best = d; + near = i; + } + }); + if (near >= 0) { + drag.current = { id: e.pointerId, at: near }; + return; + } + const next = addCurvePoint(pts, p.x, p.y); + if (next === pts) return; // too close to a neighbour to take another point + onChange(put(next)); + // The points the old list already held keep their identity, so the one + // without it is the one just added — and the drag continues on it, which is + // what makes "click, then pull" one gesture. + drag.current = { id: e.pointerId, at: Math.max(0, next.findIndex((q) => !pts.includes(q))) }; + }; + + const move = (e: React.PointerEvent) => { + const d = drag.current; + if (!d || d.id !== e.pointerId) return; + const p = at(e); + if (!p) return; + onChange(put(moveCurvePoint(pts, d.at, p.x, p.y))); + }; + + const up = (e: React.PointerEvent) => { + if (drag.current?.id === e.pointerId) drag.current = null; + }; + + const grid = [0, 1, 2, 3, 4].map((i) => (i * SIZE) / 4); + + return ( +
e.stopPropagation()}> +
+ TONE CURVE + +
+
+ {CURVE_CHANNELS.map((ch) => ( + + ))} + +
+ { + // A double-click takes away the point under it. It listens on the + // graph rather than on the circle: taking hold of a point captures the + // pointer, and the click that follows is delivered to the capture + // element, so the circle itself would never hear it. + const p = at(e); + if (!p) return; + let near = -1; + let best = HIT / SIZE; + pts.forEach((q, i) => { + const d = Math.hypot(q[0] - p.x, q[1] - p.y); + if (d < best) { + best = d; + near = i; + } + }); + if (near < 0) return; + drag.current = null; + onChange(put(removeCurvePoint(pts, near))); + }} + > + {grid.map((g) => ( + + ))} + {grid.map((g) => ( + + ))} + {/* The diagonal is the neutral line: a curve that never leaves it does + nothing, which is what makes "how far has this moved" readable. */} + + {channel !== 'rgb' ? : null} + + {pts.map((p, i) => ( + + ))} + +
+ ); +}