From 56d4b9df67e8b2589885072641fc246fa59b935f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 20:07:51 +0700 Subject: [PATCH] web: give LIGHT a tone curve, edited on the graph drawn over the photo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LIGHT rail was sliders only, so the one control that describes a tone mapping rather than a scalar had nowhere to live. It now has a TONE CURVE chip; pressing it puts a curve graph on the photo itself — four channels, RGB plus R, G and B, exactly the shape Lightroom's point curve has — and dragging a point bends the picture under it while you drag. A recipe carries the curve as `adjustments.toneCurve`, an optional map from channel to point list, `Partial>`. The field is optional and the API stores the recipe JSON opaquely, so every recipe and session written before this commit loads unchanged and simply has no curve; nothing on the API or in the database moved. The renderer never sees the points. `shared/utils/toneCurve.ts` turns them into a 256-entry table per channel and the shader looks the table up in a 256x1 texture: SkSL indexes uniform arrays by constant only, so a per-pixel lookup has to come from a texture, and a table is the cheaper shape anyway — one `lut.eval(vec2(v * 255 + 0.5, 0.5))` per channel. The interpolation between points is a monotone cubic (Fritsch–Carlson) rather than a natural spline, because a spline overshoots between two close points and that overshoot is the classic tone-curve tell, a bright halo beside a lifted shadow; a monotone cubic through the points bends through them and never turns back on itself. The table is built per channel and then composited through the master, the order the graph draws it in, so an R point in the shadows survives an RGB contrast S and both land where the lines say. Render passes: the curve rides the existing `renderPhoto`, as pass 3e, last — after the stock, the matrix, the mixer and the seasonal grade, so a point placed on the graph is the last word on that pixel. Preview and export both call `renderPhoto`, so the two agree by construction rather than by two matching implementations. The pass wraps whatever shader the pipeline had built (`paintShader ?? imageShaderOf()`) as a child of the curve shader, and counts towards `graded` for the same reason the tone shader does: the curve reads the matrix's output, so when there is a matrix it has to be in the pixels the curve samples. Turning the curve on costs one extra render pass and nothing else; off, `curveIsActive` is false and the pass is not built at all. That pass is also where this spent its time being invisible. The curve data reached the recipe and the pixels did not move: `Skia.Image.MakeImage` does not exist in the shim, so the call threw a TypeError inside the render, the preview effect's catch swallowed it into `setError('err.generic')`, and the chip, the graph and the recipe all looked healthy while the canvas kept the old frame. The fix is in `skiaShim.ts`: CanvasKit keeps that factory top-level (`Skia.MakeImage`) and only puts the encoded and lazy ones under `Image.`, and its ImageInfo insists on an explicit `colorSpace` where RN Skia's does not — everything this pipeline builds is sRGB, so the shim fills it in and the call site keeps RN Skia's shape. Reproduced in Node first (`curve-skia-lab.cjs`, scratchpad): the shim's call throws, the translated one returns a 256x1 image. `ToneCurvePanel.tsx` is the graph: a 224px SVG over the photo's layout box, no zoom transform, grid plus a dashed diagonal, the composite drawn as a ghost behind a channel line so a channel edit is still visible against the other three. Ends are pinned to x 0 and 1, a point cannot be dragged past its neighbours (2% of the axis is the closest they may sit) and cannot be dragged out of the square, so the graph can never describe a curve the renderer cannot apply. One pointerdown grabs the nearest point inside 11px or adds one on the line under the cursor and keeps dragging, so a click is a point and a drag is a bend. Deleting a point is the graph's own double-click, not the circle's, and it has to be: grabbing a point takes pointer capture, so the click that follows is delivered to the SVG rather than the circle under the cursor. RESET clears the whole graph, all four channels, and hands back an empty object that `App.tsx` maps to `undefined` so the recipe drops the field rather than keeping a `toneCurve: {}` — the field's presence is what "this picture has a curve" means, and an empty map that means the same as no map is a state two pieces of code would eventually disagree about. One undo step per visit to the graph, the rule the ruler and the watermark box already ride: a drag is one edit, not one per pointer move. No new i18n keys: the chip and the panel labels are literal uppercase, the same as EXPOSURE and STRAIGHTEN beside them. Not PRO-gated — the curve is a LIGHT control like the rest of the tab. Verified: tone-curve-probe.cjs (new, scratchpad) — a 256x256 greyscale ramp uploaded to http://localhost:8090, pixels read back off the built app. 33 PASS, 0 FAIL, no page errors. The ramp is a ramp before (9..246), a flat curve is two points and no pass, the graph is drawn on the photo (graph 729,280 240x291 against photo 719,325 256x256), every stop of the ramp lands on the drawn curve (worst deviation 1), black lifts to 132 while white holds 246 -> 252, a point dragged up bends the line itself (M0.00 112.00 L3.50 110.2...), the R tab takes the graph over while the composite stays visible behind it and R drives red at black to 255 with G and B still on the composite (133,132 against 132), the recipe carries toneCurve, it survives a reload (254 -> 254, chip still amber), a click adds a point and a double-click removes it again, RESET returns the ramp to its start (worst 0) and drops the field, and close takes the graph off the photo. tone-curve-math.cjs (new, scratchpad) — the panel's and the table's own arithmetic, 11/11: the ends pin and sort, a dragged point lifts where the graph says, a steeper segment never turns back on itself, a channel curve runs before the composite, a click lands on the line, two points cannot share a spot, an end cannot leave the axis, and the two ends survive a delete where a middle point does not. Regressions against the rebuilt app, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10. web tsc --noEmit clean. ponytail: the graph is anchored over the photo, not draggable — it sits at the photo's own layout box the way the crop frame and the straighten ruler do, and the one time it would want to move it is when the photo under it is small, at which point a token drag offset is cheaper than the second positioning system. Parametric curves (Lightroom's shadows/highlights/darks/lights) are not here: the point curve is the one the request asked for, and a parametric curve is a second graph, not a second line on this one — add it as another channel row when someone asks. The LUT is a texture rather than Skia's table colour filter because CanvasKit 0.42 has no ColorFilter.MakeTable. The panel's graph size and hit radius are literals, since exactly one graph exists. --- docker/frontend/shared/types/index.ts | 16 ++ docker/frontend/shared/utils/toneCurve.ts | 203 +++++++++++++++++++ docker/frontend/src/App.tsx | 39 +++- docker/frontend/src/engine/exportEngine.ts | 50 ++++- docker/frontend/src/engine/skiaShim.ts | 9 + docker/frontend/src/styles/app.css | 78 ++++++++ docker/frontend/src/ui/ImageStage.tsx | 27 ++- docker/frontend/src/ui/ToneCurvePanel.tsx | 217 +++++++++++++++++++++ 8 files changed, 632 insertions(+), 7 deletions(-) create mode 100644 docker/frontend/shared/utils/toneCurve.ts create mode 100644 docker/frontend/src/ui/ToneCurvePanel.tsx 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) => ( + + ))} + +
+ ); +}