web: the eyedropper reads a colour and the mixer moves that hue band
This commit is contained in:
@@ -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<Record<HslBandId, HslBand>>;
|
||||
exposureCompensation: number; // -3 to +3 EV. Camera: AE bias (hardware). Library: 2^EV matrix gain.
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<HslBandId, HslBand>> | undefined {
|
||||
if (!raw || typeof raw !== 'object') return undefined;
|
||||
const out: Partial<Record<HslBandId, HslBand>> = {};
|
||||
for (const band of HSL_BANDS) {
|
||||
const v = (raw as Record<string, unknown>)[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
|
||||
|
||||
@@ -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<Recipe, 'id' | 'isCustom'> {
|
||||
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<ColorAdjustments>) };
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -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` / `<Shader uniforms>` — 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 ||
|
||||
|
||||
+111
-1
@@ -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<string | null>(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<HslBandId>('red');
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null);
|
||||
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
|
||||
const [showRecipes, setShowRecipes] = useState(false);
|
||||
const [tab, setTab] = useState<TabId>('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() {
|
||||
<ChipRow chips={chips} />
|
||||
)}
|
||||
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : 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' ? (
|
||||
<div className="hsl-readout" data-key="hsl-readout">
|
||||
{sample && picked ? (
|
||||
<>
|
||||
<span
|
||||
className="hsl-swatch"
|
||||
data-key="hsl-swatch"
|
||||
data-color={`rgb(${sample.r}, ${sample.g}, ${sample.b})`}
|
||||
style={{ background: `rgb(${sample.r}, ${sample.g}, ${sample.b})` }}
|
||||
/>
|
||||
<span className="hsl-line" data-key="hsl-rgb">
|
||||
R {sample.r} · G {sample.g} · B {sample.b}
|
||||
</span>
|
||||
<span className="hsl-line" data-key="hsl-hsl">
|
||||
{Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}%
|
||||
</span>
|
||||
<span className="hsl-line" data-key="hsl-band">
|
||||
{HSL_BANDS.find((b) => b.id === hslBand)?.label}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<p className="hint">{t('hsl.hint')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : 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. */}
|
||||
<div className="reset-row">
|
||||
@@ -1730,6 +1838,8 @@ export function Workspace() {
|
||||
onCropRectChange={setCropRect}
|
||||
onStageZoom={onStageZoom}
|
||||
canCompare={crop === 'none'}
|
||||
picking={picking}
|
||||
onPickColor={pickColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
// <img>), 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<HTMLInputElement>(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 ? (
|
||||
<div
|
||||
className="pick-layer"
|
||||
data-key="pick-layer"
|
||||
style={{
|
||||
left: box.left,
|
||||
top: box.top,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
transform:
|
||||
view.s === 1 ? undefined : `translate(${view.x}px, ${view.y}px) scale(${view.s})`,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={pickAt}
|
||||
/>
|
||||
) : null}
|
||||
{compare && canCompare && originalUrl && box ? (
|
||||
<div
|
||||
className="compare-layer"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useI18n } from '../i18n/I18nProvider';
|
||||
// eye corrects, and the web rail draws them in the order the panel opens. The
|
||||
// rail is a single column on desktop and becomes the bottom bar under 860px
|
||||
// (see styles/app.css).
|
||||
export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'frame' | 'save' | 'create';
|
||||
export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'hsl' | 'frame' | 'save' | 'create';
|
||||
|
||||
export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
|
||||
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
|
||||
@@ -14,6 +14,8 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
|
||||
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
|
||||
{ id: 'light', label: 'tab.light', glyph: '☀' },
|
||||
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
|
||||
// The HSL mixer sits with the colour tabs: it edits one hue band at a time.
|
||||
{ id: 'hsl', label: 'tab.hsl', glyph: '◍' },
|
||||
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
|
||||
// Keeping the look on the stage and building one from scratch are two acts,
|
||||
// so they get two tabs: SAVE RECENT files what is open, CREATE RECIPES starts
|
||||
|
||||
Reference in New Issue
Block a user