From 1b71c0196fa04e824d7e7217763a81104c277699 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 17:41:45 +0700 Subject: [PATCH] web: the eyedropper reads a colour and the mixer moves that hue band --- docker/frontend/shared/types/index.ts | 13 ++ docker/frontend/shared/utils/colorUtils.ts | 88 +++++++++++++- docker/frontend/shared/utils/recipeShare.ts | 6 + docker/frontend/shared/utils/toneShader.ts | 128 +++++++++++++++++++- docker/frontend/src/App.tsx | 112 ++++++++++++++++- docker/frontend/src/engine/imageOps.ts | 19 +++ docker/frontend/src/i18n/en.ts | 2 + docker/frontend/src/i18n/vi.ts | 2 + docker/frontend/src/styles/app.css | 19 +++ docker/frontend/src/ui/ImageStage.tsx | 63 +++++++++- docker/frontend/src/ui/ToolRail.tsx | 4 +- 11 files changed, 449 insertions(+), 7 deletions(-) diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index f16a24d..7358792 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -70,6 +70,16 @@ export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter'; export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none'; +// The eight hue bands of the selective-colour mixer, in the order they wrap +// around the wheel. HSL_BANDS (shared/utils/colorUtils.ts) carries the anchor +// hue each id stands for; the eyedropper picks one from the sampled colour. +export type HslBandId = 'red' | 'orange' | 'yellow' | 'green' | 'aqua' | 'blue' | 'purple' | 'magenta'; + +// One band's edit: [hue, saturation, lightness], each -10..+10 like every other +// knob. A band that is all zero is dropped rather than stored, so the object +// stays empty until the mixer is actually used. +export type HslBand = [number, number, number]; + export interface ColorAdjustments { exposure: number; // -10 to +10 (mapped to matrix multiplier or offset) contrast: number; // -10 to +10 @@ -92,6 +102,9 @@ export interface ColorAdjustments { hdf?: number; // 0 to 10 (Highlight Diffusion Filter: glow on the highlights only) vignette?: number; // 0 to 10 (darken the four corners; 0 = off) vibrance?: number; // -10 to +10 (chroma-masked saturation: vivid colours move least) + // Selective colour by hue band. Only the bands the user actually moved are + // present, so an untouched mixer costs nothing and an old recipe stays valid. + hslBands?: Partial>; exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain. } diff --git a/docker/frontend/shared/utils/colorUtils.ts b/docker/frontend/shared/utils/colorUtils.ts index 4d8dbf2..a452979 100644 --- a/docker/frontend/shared/utils/colorUtils.ts +++ b/docker/frontend/shared/utils/colorUtils.ts @@ -1,4 +1,90 @@ -import { ColorAdjustments, BaseFilter } from '../types'; +import { ColorAdjustments, BaseFilter, HslBand, HslBandId } from '../types'; + +// The eight bands of the selective-colour mixer, and the anchor hue each one +// stands for (degrees on the HSL wheel). The anchors are unevenly spaced on +// purpose: red/orange/yellow sit 30° apart because the eye separates them +// finely, while the blue side is one 60° stretch. TONE_SKSL builds the same +// eight weights from this table, so the anchor a band is edited at and the +// anchor the shader masks at can never drift apart. +// +// `label` is what the chip prints — the phone's own strings there too, so it is +// not in the i18n dictionary. +export const HSL_BANDS: { id: HslBandId; hue: number; label: string }[] = [ + { id: 'red', hue: 0, label: 'RED' }, + { id: 'orange', hue: 30, label: 'ORANGE' }, + { id: 'yellow', hue: 60, label: 'YELLOW' }, + { id: 'green', hue: 120, label: 'GREEN' }, + { id: 'aqua', hue: 180, label: 'AQUA' }, + { id: 'blue', hue: 240, label: 'BLUE' }, + { id: 'purple', hue: 280, label: 'PURPLE' }, + { id: 'magenta', hue: 320, label: 'MAGENTA' }, +]; + +// Hue (degrees) the two neighbours of each band sit at, as TONE_SKSL needs +// them. Derived here so the two tables cannot disagree: the tent weight of a +// band falls to 0 at its neighbours' anchors, which makes the eight weights a +// partition of unity on the wheel (they sum to 1 at every hue, so a pixel is +// never counted twice and never falls in a gap). +export function hslBandGaps(): { id: HslBandId; hue: number; left: number; right: number }[] { + const n = HSL_BANDS.length; + return HSL_BANDS.map((b, i) => { + const prev = HSL_BANDS[(i - 1 + n) % n].hue; + const next = HSL_BANDS[(i + 1) % n].hue; + // Red (0°) wraps: its left neighbour is magenta at 320°, 40° back. + return { id: b.id, hue: b.hue, left: (b.hue - prev + 360) % 360, right: (next - b.hue + 360) % 360 }; + }); +} + +// 0..255 sRGB to HSL. Hue in degrees, saturation and lightness 0..1 — the +// reverse of hslToRgb below, and what the eyedropper readout prints. +export function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + const R = r / 255; + const G = g / 255; + const B = b / 255; + const mx = Math.max(R, G, B); + const mn = Math.min(R, G, B); + const l = (mx + mn) / 2; + const d = mx - mn; + if (d < 1e-6) return { h: 0, s: 0, l }; + const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn); + let h: number; + if (mx === R) h = ((G - B) / d + (G < B ? 6 : 0)) * 60; + else if (mx === G) h = ((B - R) / d + 2) * 60; + else h = ((R - G) / d + 4) * 60; + return { h, s, l }; +} + +// The band a sampled hue belongs to: the nearest anchor. Bands overlap in the +// shader, so this only decides which one the ruler is pointed at. +export function nearestHslBand(hue: number): HslBandId { + const wrapped = ((hue % 360) + 360) % 360; + let best = HSL_BANDS[0]; + let bestD = 361; + for (const band of HSL_BANDS) { + const d = Math.abs(((wrapped - band.hue + 540) % 360) - 180); + if (d < bestD) { + bestD = d; + best = band; + } + } + return best.id; +} + +// A band triple as stored: integers in -10..10, and an all-zero band is dropped +// so the recipe only carries what the user moved. +const clamp10 = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? Math.max(-10, Math.min(10, Math.round(v))) : 0); + +export function sanitizeHslBands(raw: unknown): Partial> | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const out: Partial> = {}; + for (const band of HSL_BANDS) { + const v = (raw as Record)[band.id]; + if (!Array.isArray(v) || v.length !== 3) continue; + const triple: HslBand = [clamp10(v[0]), clamp10(v[1]), clamp10(v[2])]; + if (triple[0] || triple[1] || triple[2]) out[band.id] = triple; + } + return Object.keys(out).length ? out : undefined; +} // Kelvin to RGB compensation for White Balance. // The WB setting names the light the camera is told to expect, so its gain is diff --git a/docker/frontend/shared/utils/recipeShare.ts b/docker/frontend/shared/utils/recipeShare.ts index d3c5c33..471b65a 100644 --- a/docker/frontend/shared/utils/recipeShare.ts +++ b/docker/frontend/shared/utils/recipeShare.ts @@ -1,5 +1,6 @@ import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types'; import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes'; +import { sanitizeHslBands } from './colorUtils'; // Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the // recipe JSON, so a shared recipe imports back into the app on another phone. @@ -94,6 +95,11 @@ export function importRecipeXml(xml: string): Omit { const name = raw.name.trim().slice(0, 40) || 'Imported recipe'; const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none'; const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial) }; + // The mixer is the one adjustment that arrives as a nested object, so it is + // the one that needs its own gate: unknown band ids go, every value is + // clamped to the knob range, and a band left at zero is dropped rather than + // carried around. + adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands); const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none'; return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag }; } diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 4346215..e39028d 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -1,4 +1,5 @@ import { BaseFilter, ColorAdjustments } from '../types'; +import { HSL_BANDS, hslBandGaps } from './colorUtils'; // Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow). // SkSL runtime effect over a child image shader. @@ -41,6 +42,24 @@ import { BaseFilter, ColorAdjustments } from '../types'; // warm highlights at once, so the stock ships these values and the pass // stays active for it even when every user knob is 0. All-zero still = no // pass. +// hslOn/hslH/hslS/hslL - the selective-colour mixer: eight hue bands, each with +// a hue shift, a saturation scale and a lightness offset (-1..1, from the +// -10..10 knobs). Which pixels a band owns is decided HERE, per pixel, by +// hue — so unlike everything else above it, the eight bands are not a +// global move and cannot live in the colour matrix. See the band block at +// the foot of TONE_SKSL. +// The eight band lines of TONE_SKSL's mixer, generated from HSL_BANDS so the +// anchors and the gaps in the shader are the same numbers the chips are built +// from. Each line reads its own band's three values with a CONSTANT index — +// SkSL indexes uniform arrays by constant only, which is why this is unrolled +// rather than looped. +const BAND_BLOCK = hslBandGaps() + .map( + (b, i) => ` float w${i} = bandW(hd, ${b.hue.toFixed(1)}, ${b.left.toFixed(1)}, ${b.right.toFixed(1)}) * gate; + acc += vec3(w${i} * hslH[${i}], w${i} * hslS[${i}], w${i} * hslL[${i}]);\n` + ) + .join(''); + export const TONE_SKSL = ` uniform shader src; uniform float dr; @@ -57,6 +76,54 @@ uniform float hlTg; uniform float hlTb; uniform float cc; uniform float ccb; +uniform float hslOn; +uniform float hslH[8]; +uniform float hslS[8]; +uniform float hslL[8]; +// sRGB <-> HSL. The mixer works in HSL because that is the space the knobs are +// named after: a hue shift must not change how light a colour is, and a +// lightness move must not change its hue, which is exactly what scaling RGB +// does wrong. +vec3 rgb2hsl(vec3 c) { + float mx = max(max(c.r, c.g), c.b); + float mn = min(min(c.r, c.g), c.b); + float l = (mx + mn) * 0.5; + float d = mx - mn; + if (d < 0.00001) return vec3(0.0, 0.0, l); + float s = l > 0.5 ? d / max(0.00001, 2.0 - mx - mn) : d / max(0.00001, mx + mn); + float h; + if (mx == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0); + else if (mx == c.g) h = (c.b - c.r) / d + 2.0; + else h = (c.r - c.g) / d + 4.0; + return vec3(h / 6.0, s, l); +} +float hueChannel(float p, float q, float t) { + t = fract(t); + if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; + if (t < 0.5) return q; + if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; + return p; +} +vec3 hsl2rgb(vec3 hsl) { + if (hsl.y < 0.00001) return vec3(hsl.z); + float q = hsl.z < 0.5 ? hsl.z * (1.0 + hsl.y) : hsl.z + hsl.y - hsl.z * hsl.y; + float p = 2.0 * hsl.z - q; + return vec3( + hueChannel(p, q, hsl.x + 1.0 / 3.0), + hueChannel(p, q, hsl.x), + hueChannel(p, q, hsl.x - 1.0 / 3.0) + ); +} +// One band's ownership of a hue: full at the band's own anchor, falling +// linearly to 0 at each neighbour's anchor (the gaps are uneven — red sits 30° +// from orange and 40° from magenta). Linearity is the point: adjacent tents +// cross at exactly 0.5 at the midpoint, so the eight weights sum to 1 at every +// hue. No pixel is counted twice, no pixel falls between two bands, and a hue +// sitting on an anchor gets that band's full value instead of a share of it. +float bandW(float hue, float anchor, float gapL, float gapR) { + float d = mod(hue - anchor + 180.0, 360.0) - 180.0; + return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR); +} vec4 main(vec2 xy) { vec4 c = src.eval(xy); vec3 rgb = clamp(c.rgb, 0.0, 1.0); @@ -117,7 +184,31 @@ vec4 main(vec2 xy) { 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); + rgb = clamp(mix(vec3(l2), rgb, kv), 0.0, 1.0); + // Selective colour by hue band — the last move, so a band edit is judged + // against the colour the user actually sampled off the render. + // + // A grey is dropped before the weights are read: rgb2hsl hands it hue 0, so + // without this fade every neutral pixel in the frame would be treated as + // pure red and slide with the red band. Below 8% saturation there is no hue + // to move anyway. + // + // The three accumulators are the band values scaled by ownership, so a hue + // landing between two anchors gets a proportional mix of the two edits — + // the same blend the weights already sum to. Hue is a turn (±30° at full), + // saturation is a scale (0 = grey at -10), lightness is additive (±0.25 at + // full) so it cannot invert the ramp. + if (hslOn > 0.5) { + vec3 hsl = rgb2hsl(rgb); + float gate = smoothstep(0.0, 0.08, hsl.y); + float hd = hsl.x * 360.0; + vec3 acc = vec3(0.0); +${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0)); + hsl.y = clamp(hsl.y * (1.0 + acc.y), 0.0, 1.0); + hsl.z = clamp(hsl.z + acc.z * 0.25, 0.0, 1.0); + rgb = hsl2rgb(hsl); + } + return vec4(clamp(rgb, 0.0, 1.0), c.a); } `; @@ -198,6 +289,10 @@ export interface ToneUniforms { hlT: [number, number, number]; // highlight split-tone RGB bias, -1..1 cc: number; // 0..1 Color Chrome depth (0 = 'none') ccb: number; // 0..1 Color Chrome FX Blue depth (0 = 'none') + hslOn: number; // 1 when any band is moved (0 skips the whole mixer) + hslH: number[]; // 8 × -1..1 per band, in HSL_BANDS order (±30° of hue at full) + hslS: number[]; // 8 × -1..1 per band (saturation scale, -1 = grey) + hslL: number[]; // 8 × -1..1 per band (additive lightness, ±0.25 at full) } // Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix: @@ -242,6 +337,26 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): !colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45; const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) => !colour || v === 'none' || v == null ? 0 : v === 'strong' ? 1.0 : 0.5; + // Selective colour: one slot per band, in HSL_BANDS order, so the flat buffer + // lines up with the shader's arrays. A band the user has not moved holds + // three zeroes and costs nothing but its slot. + const bands = adj.hslBands ?? {}; + const tenth = (v: unknown) => + typeof v === 'number' && Number.isFinite(v) ? Math.max(-1, Math.min(1, v / 10)) : 0; + const hslH: number[] = []; + const hslS: number[] = []; + const hslL: number[] = []; + let hslOn = 0; + for (const band of HSL_BANDS) { + const v = bands[band.id]; + const [h, s, l] = v ? [tenth(v[0]), tenth(v[1]), tenth(v[2])] : [0, 0, 0]; + hslH.push(h); + hslS.push(s); + hslL.push(l); + if (h || s || l) hslOn = 1; + } + // A monochrome stock has no hue to be selective about. + if (!colour) hslOn = 0; return { dr, hl: hl + (film.hl ?? 0), @@ -253,17 +368,26 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): hlT, cc: chromeDepth(adj.colorChrome), ccb: blueDepth(adj.colorChromeBlue), + hslOn, + hslH, + hslS, + hslL, }; } // Flat uniform buffer for `makeShaderWithChildren` / `` — the // order must match TONE_SKSL's declarations. export function toneUniformArray(u: ToneUniforms): number[] { - return [u.dr, u.hl, u.sh, u.wh, u.bl, u.vib, u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb]; + return [ + u.dr, u.hl, u.sh, u.wh, u.bl, u.vib, + u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb, + u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, + ]; } export function toneIsActive(u: ToneUniforms): boolean { return ( + u.hslOn !== 0 || u.dr !== 0 || u.hl !== 0 || u.sh !== 0 || diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 3088d27..fb2a7b8 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -25,8 +25,11 @@ import { type CropRect, type FrameId, type GPSInfo, + type HslBand, + type HslBandId, type Recipe, } from '../shared/types'; +import { HSL_BANDS, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. @@ -183,6 +186,11 @@ export function Workspace() { const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null); const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0); const [openParam, setOpenParam] = useState(null); + // The HSL mixer: which hue band the ruler edits, whether the eyedropper is + // armed, and the last colour it read off the photo. + const [hslBand, setHslBand] = useState('red'); + const [picking, setPicking] = useState(false); + const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null); const [openGroup, setOpenGroup] = useState(null); const [showRecipes, setShowRecipes] = useState(false); const [tab, setTab] = useState('presets'); @@ -1047,6 +1055,8 @@ export function Workspace() { setOpenParam(null); setOpenGroup(null); setShowRecipes(false); + // Leaving the mixer disarms the eyedropper: it is a mode of that tab. + setPicking(false); }, [tab]); // Continuous parameter chips: label + live value; amber text when edited, @@ -1068,6 +1078,37 @@ export function Workspace() { const slider: SliderDef | null = useMemo(() => { if (!openParam) return null; + // The mixer's three knobs are not in PARAM_DEFS: they read and write one + // band of hslBands rather than a flat field of adjustments. + if (openParam.startsWith('hsl.')) { + const which = openParam === 'hsl.h' ? 0 : openParam === 'hsl.s' ? 1 : 2; + const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); + const value = band[which]; + const set = (v: number) => { + const next: HslBand = [band[0], band[1], band[2]]; + next[which] = Math.round(v); + const bands = { ...recipe.adjustments.hslBands }; + // An all-zero band is dropped, so an untouched band never lands in the + // recipe and a band put back to zero leaves no trace. + if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand]; + else bands[hslBand] = next; + setAdjustmentOnce({ hslBands: bands }); + }; + const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM'; + return { + key: openParam, + label: `${name} ${HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''}`, + value, + min: -10, + max: 10, + step: 1, + display: value > 0 ? `+${value}` : String(value), + edited: value !== 0, + onChange: set, + onReset: () => set(0), + onBack: () => setOpenParam(null), + }; + } const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters]; const def = all.find((d) => d.key === openParam); if (!def) return null; @@ -1092,7 +1133,16 @@ export function Workspace() { }, }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [openParam, recipe.adjustments, setAdjustmentOnce]); + }, [openParam, recipe.adjustments, setAdjustmentOnce, hslBand]); + + // The eyedropper's report: print what was read, point the ruler at that + // colour's band, and put the tool away — one pick is one colour. + const pickColor = useCallback((rgb: { r: number; g: number; b: number }) => { + setSample(rgb); + setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h)); + setPicking(false); + }, []); + const picked = sample ? rgbToHsl(sample.r, sample.g, sample.b) : null; // Option strips: a chip opens one, each option picks a value. `off` is the // neutral value the chip glows amber against. @@ -1276,6 +1326,7 @@ export function Workspace() { const tweaked = (Object.keys(DEFAULT_ADJUSTMENTS) as (keyof typeof DEFAULT_ADJUSTMENTS)[]) .some((k) => a[k] !== DEFAULT_ADJUSTMENTS[k]); return tweaked + || Object.keys(a.hslBands ?? {}).length > 0 || (simId ?? 'sim-provia') !== 'sim-provia' || frameId !== 'none' || crop !== 'none' || rotation !== 0 || markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1 @@ -1425,6 +1476,36 @@ export function Workspace() { { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, ...paramChips(PARAM_DEFS.filters), ]; + case 'hsl': { + // PICK arms the eyedropper; the eight band chips choose which band the + // ruler edits, and the ruler itself is opened by the three knobs below. + const cur = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); + const knob = (key: string, label: string, i: number): ChipDef => ({ + key, + label, + value: cur[i] > 0 ? `+${cur[i]}` : String(cur[i]), + active: openParam === key, + amberValue: openParam !== key && cur[i] !== 0, + onClick: () => toggleParam(key), + }); + return [ + { key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => setPicking((v) => !v) }, + ...HSL_BANDS.map((b): ChipDef => { + const band = recipe.adjustments.hslBands?.[b.id]; + const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0); + return { + key: `hsl-band-${b.id}`, + label: b.label, + active: hslBand === b.id, + amberValue: hslBand !== b.id && moved, + onClick: () => setHslBand(b.id), + }; + }), + knob('hsl.h', 'HUE', 0), + knob('hsl.s', 'SAT', 1), + knob('hsl.l', 'LUM', 2), + ]; + } case 'frame': // WATERMARK is a chip like the rest: its own sub-panel opens as the next // column (see the wm column in the render), so the frame chips stay put. @@ -1580,6 +1661,33 @@ export function Workspace() { )} {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} + {/* The mixer's readout: the colour the eyedropper last read, as the + RGB bytes and the HSL the band is indexed by. */} + {tab === 'hsl' ? ( +
+ {sample && picked ? ( + <> + + + R {sample.r} · G {sample.g} · B {sample.b} + + + {Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}% + + + {HSL_BANDS.find((b) => b.id === hslBand)?.label} + + + ) : ( +

{t('hsl.hint')}

+ )} +
+ ) : null} {/* RESET is the way out of an edit, not one more chip: it sits under a rule at the foot of the column so it reads as its own thing. */}
@@ -1730,6 +1838,8 @@ export function Workspace() { onCropRectChange={setCropRect} onStageZoom={onStageZoom} canCompare={crop === 'none'} + picking={picking} + onPickColor={pickColor} />
diff --git a/docker/frontend/src/engine/imageOps.ts b/docker/frontend/src/engine/imageOps.ts index 15c4a7e..64126b6 100644 --- a/docker/frontend/src/engine/imageOps.ts +++ b/docker/frontend/src/engine/imageOps.ts @@ -31,6 +31,25 @@ export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0 } } +// The colour under one point of a photo, for the HSL mixer's eyedropper. +// `fx`/`fy` are fractions of the photo (0..1, from the click position over the +// ), so the caller needs no knowledge of the stage's zoom, pan or box. +// One pixel, read straight off the RENDER — the swatch then shows the colour +// the user is actually looking at, grade and all, which is what makes the +// band the eyedropper lands on the one they meant. +export function samplePixel(bitmap: ImageBitmap, fx: number, fy: number): { r: number; g: number; b: number } { + const sx = Math.min(bitmap.width - 1, Math.max(0, Math.floor(fx * bitmap.width))); + const sy = Math.min(bitmap.height - 1, Math.max(0, Math.floor(fy * bitmap.height))); + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return { r: 0, g: 0, b: 0 }; + ctx.drawImage(bitmap, sx, sy, 1, 1, 0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + return { r, g, b }; +} + // Uploads are capped by the API (and by nginx in front of it), so a phone's // 12MP JPEG has to shrink before it goes up — the same downscale the preview // uses. The ORIGINAL bytes come back untouched (same reference) when the photo diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 65a5da7..467b34c 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -38,6 +38,8 @@ export const en: Dict = { 'tab.light': 'LIGHT', 'tab.wb': 'WB', 'tab.fx': 'FX', + 'tab.hsl': 'HSL', + 'hsl.hint': 'PRESS PICK, THEN SAMPLE A COLOUR ON THE PHOTO', 'tab.frame': 'FRAME', 'tab.save': 'SAVE RECENT', 'tab.create': 'CREATE RECIPES', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 5465f5d..f065cba 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -46,6 +46,8 @@ export const vi = { 'tab.light': 'LIGHT', 'tab.wb': 'WB', 'tab.fx': 'FX', + 'tab.hsl': 'HSL', + 'hsl.hint': 'BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH', 'tab.frame': 'FRAME', 'tab.save': 'LƯU GẦN ĐÂY', 'tab.create': 'CREATE RECIPES', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 56d875a..bac34b0 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -295,6 +295,25 @@ 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; } +/* Eyedropper: the photo's own box, left transparent, with a crosshair cursor. */ +.pick-layer { + position: absolute; + border-radius: var(--radius-sm); + cursor: crosshair; + touch-action: none; +} +/* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the + ruler is pointed at. */ +.hsl-readout { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.hsl-swatch { display: block; height: 34px; border: 1px solid var(--border); border-radius: var(--radius-sm); } +.hsl-line { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; } /* Histogram overlay: a small translucent frame on the photo, dragged by its header. The luma curve is a grey fill behind the three channel curves, which screen over each other the way a Lightroom histogram reads. */ diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index d27c812..58bc620 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { MIN_CROP_FRAC, type CropRect } from '../../shared/types'; +import { samplePixel } from '../engine/imageOps'; import { Histogram } from './Histogram'; // One wheel notch, and how far the photo may be pulled open. @@ -26,6 +27,8 @@ export function ImageStage({ onCropRectChange, onStageZoom, canCompare = false, + picking = false, + onPickColor, }: { previewUrl: string | null; originalUrl: string | null; @@ -47,6 +50,10 @@ export function ImageStage({ // Compare needs the un-cropped photo on one side and the render on the other, // so it is only offered while the crop is untouched. canCompare?: boolean; + // Eyedropper mode: clicks on the photo report the colour underneath instead of + // panning it. The HSL mixer's PICK chip owns this flag. + picking?: boolean; + onPickColor?: (rgb: { r: number; g: number; b: number }) => void; }) { const { t } = useI18n(); const inputRef = useRef(null); @@ -114,14 +121,14 @@ export function ImageStage({ }, []); useEffect(() => { - if (!cropEditing && !compare) { + if (!cropEditing && !compare && !picking) { setBox(null); return; } measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); - }, [cropEditing, compare, measure, previewUrl]); + }, [cropEditing, compare, picking, measure, previewUrl]); // Compare is a property of the un-cropped photo, so a crop taking over (or a // new photo arriving) puts it away. @@ -132,6 +139,39 @@ export function ImageStage({ setCompare(false); }, [originalUrl]); + // Eyedropper. The decode is done on the click, not when PICK is armed: a + // bitmap made up front would be stale after the first band move, and re-making + // it on every render would decode a fresh JPEG per slider step. One bitmap is + // kept for as long as the URL it came from is the one on the stage. + const pickCache = useRef<{ url: string; bmp: ImageBitmap } | null>(null); + useEffect( + () => () => { + pickCache.current?.bmp.close(); + pickCache.current = null; + }, + [] + ); + const pickAt = useCallback( + async (e: React.MouseEvent) => { + const img = imgRef.current; + if (!img || !previewUrl || !onPickColor) return; + // The rect is the photo's own box however the stage has zoomed or panned + // it, so a click maps straight to a fraction of the render. + const rect = img.getBoundingClientRect(); + if (!(rect.width > 0 && rect.height > 0)) return; + const fx = (e.clientX - rect.left) / rect.width; + const fy = (e.clientY - rect.top) / rect.height; + if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return; + if (!pickCache.current || pickCache.current.url !== previewUrl) { + const blob = await (await fetch(previewUrl)).blob(); + pickCache.current?.bmp.close(); + pickCache.current = { url: previewUrl, bmp: await createImageBitmap(blob) }; + } + onPickColor(samplePixel(pickCache.current.bmp, fx, fy)); + }, + [previewUrl, onPickColor] + ); + // The img's box at scale 1 — the natural size fitted into the wrap, never // enlarged. Computed rather than measured so the transform cannot feed back // into it. @@ -424,6 +464,25 @@ export function ImageStage({ } } /> + {/* Eyedropper. Sits over the photo so a click reads the colour under + the cursor; it swallows the pointer so the pan gesture never + starts. */} + {picking && !cropEditing && box ? ( +
e.stopPropagation()} + onClick={pickAt} + /> + ) : null} {compare && canCompare && originalUrl && box ? (