From eb47afc63e3e231aedf62710b1849b4808f81b4e Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 7 Oct 2026 12:36:55 +0700 Subject: [PATCH] feat(studio & library): sync Light & Color tab parameters (vibrance, highlight, shadow, white, black) to Gradient Mask & add multi-field sorting options --- docker/frontend/shared/types/index.ts | 5 ++ docker/frontend/shared/utils/gradientMask.ts | 32 +++++------ docker/frontend/src/App.tsx | 3 +- docker/frontend/src/Library.tsx | 59 ++++++++++++++++---- docker/frontend/src/i18n/en.ts | 6 ++ docker/frontend/src/i18n/vi.ts | 6 ++ 6 files changed, 82 insertions(+), 29 deletions(-) diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 26676fb..7c4ee06 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -166,8 +166,13 @@ export interface GradientMask { shadows?: number; whites?: number; blacks?: number; + highlight?: number; + shadow?: number; + white?: number; + black?: number; clarity?: number; dehaze?: number; + vibrance?: number; // The LIGHT column's WB pair on the mask's own pixels: the same two rulers, // the same units (kelvin, and the store's ±10 tint), the same gain — a local // white balance, which is what a mask that sits under a mixed light is for. diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts index 0ade7ec..6a55051 100644 --- a/docker/frontend/shared/utils/gradientMask.ts +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -82,6 +82,7 @@ export type ReadMask = GradientMask & { blacks: number; clarity: number; dehaze: number; + vibrance: number; temperature: number; tint: number; }; @@ -100,18 +101,19 @@ export function readMasks(masks: GradientMask[] | undefined): ReadMask[] { ry: Math.max(0, num(m.ry, 0)), angle: num(m.angle, 0), feather: clamp01(num(m.feather, MASK_DEFAULT_FEATHER)), - exposure: clampEV(num(m.exposure, 0)), + exposure: clampEV(num(m.exposure ?? (m as any).ev, 0)), contrast: clampA(num(m.contrast, 0)), - saturation: clampA(num(m.saturation, 0)), + saturation: clampA(num(m.saturation ?? (m as any).color, 0)), // The spec's section 4 knobs: the two tone soft masks, the two ends, and // the two spatial moves. All -10..+10 like every other knob here, all 0 on // a mask stored before they existed. - highlights: clampA(num(m.highlights, 0)), - shadows: clampA(num(m.shadows, 0)), - whites: clampA(num(m.whites, 0)), - blacks: clampA(num(m.blacks, 0)), + highlights: clampA(num(m.highlights ?? m.highlight, 0)), + shadows: clampA(num(m.shadows ?? m.shadow, 0)), + whites: clampA(num(m.whites ?? m.white, 0)), + blacks: clampA(num(m.blacks ?? m.black, 0)), clarity: clampA(num(m.clarity, 0)), dehaze: clampA(num(m.dehaze, 0)), + vibrance: clampA(num(m.vibrance, 0)), // The WB pair, the two rulers the LIGHT column already carries, read on the // mask's own pixels: kelvin on its own scale, tint in the store's ±10. Both // absent on a mask stored before they existed, and then the gain is 1. @@ -158,9 +160,9 @@ export function maskUniforms( [m.exposure, m.contrast / 10, m.saturation / 10, MASK_KIND[m.kind]], (2 * n + i) * 4 ); - // The tone soft masks and the two ends, then the spatial pair. + // The tone soft masks and the two ends, then the spatial pair & vibrance. u.set([m.highlights / 10, m.shadows / 10, m.whites / 10, m.blacks / 10], (3 * n + i) * 4); - u.set([m.clarity / 10, m.dehaze / 10, 0, 0], (4 * n + i) * 4); + u.set([m.clarity / 10, m.dehaze / 10, m.vibrance / 10, 0], (4 * n + i) * 4); // The mask's own white balance, resolved here and not in the shader: the // Kelvin fit is a curve, and a curve in SkSL would be a second copy of it // (colorUtils.whiteBalanceGain is the one). Three gains and a zero pad, so @@ -212,17 +214,15 @@ half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark$ // ordering clamp, the 0.50 midpoint no knob reaches, and the luma-preserving // rebuild are all the frame's. DR is the one knob the ramp also carries that a // mask does not have, so it is spent as 0 here. - // - // The BASE of that ramp is the mask's own pixel, handed in twice: a mask runs - // on the shape the user drew and has no neighbourhood of its own, and the - // degenerate call is the one the shared maths is written to take — a base equal - // to t is a ratio of exactly 1, so the mask keeps the global move it has always - // made. Give it a base of its own when a mask's SHADOW is reported the way the - // frame's was. float lf = clamp(float(l), 0.0, 1.0); c = half3(toneRamp(vec3(c), lf, lf, tone.w, tone.y, tone.x, tone.z, 0.0)); half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722)); - c = mix(half3(nl), c, half(1.0 + a.z)); + // Saturation & Vibrance matching toneShader's exact chroma-masked formula + half mx = max(max(c.r, c.g), c.b); + half mn = min(min(c.r, c.g), c.b); + half chroma = mx > half(0.0001) ? (mx - mn) / mx : half(0.0); + half kv = half(1.0) + half(fx.z) * half(0.75) * (half(1.0) - chroma); + c = clamp(mix(half3(nl), c, half(1.0 + a.z) * kv), half3(0.0), half3(1.0)); ${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason: // sharpening haze only makes it read as detail. if (fx.y != 0.0) { diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 97f04cc..62a0d20 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2676,7 +2676,7 @@ export function Workspace() { // this helper too: same ±10 unit, same ×10 ruler, same green↔magenta gain, read // on the mask's pixels — a local white balance. const maskKnobRow = ( - key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze' | 'tint', + key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze' | 'tint' | 'vibrance', label: string ): SliderDef => { const v = selMask?.[key] ?? 0; @@ -2752,6 +2752,7 @@ export function Workspace() { onChange: (v) => setMaskKnob({ saturation: Math.round(v) / 10 }), onReset: () => setMaskKnob({ saturation: 0 }), }, + maskKnobRow('vibrance', 'VIBRANCE'), maskKnobRow('highlights', 'HIGHLIGHT'), maskKnobRow('shadows', 'SHADOW'), maskKnobRow('whites', 'WHITE'), diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 1c487af..015815a 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -247,7 +247,8 @@ function Catalogue() { // these hours — and in the order the reader picked. A way of looking, not part // of the roll, so none of it outlives the visit; the strip below the frame does // not follow it, because the strip is the shelf itself. - const [order, setOrder] = useState<'newest' | 'oldest' | 'star'>('newest'); + const [sortBy, setSortBy] = useState<'taken' | 'mtime' | 'type' | 'name' | 'star'>('taken'); + const [sortDir, setSortDir] = useState<'desc' | 'asc'>('desc'); const [minStar, setMinStar] = useState(0); const [year, setYear] = useState('all'); const [from, setFrom] = useState('any'); @@ -965,12 +966,34 @@ function Catalogue() { if (to !== 'any' && at.getHours() > Number(to)) return false; return true; }); - // `shown` is already newest shutter first, so the oldest is the same list - // read backwards; the score comes first in the third, and the shutter breaks - // its ties the way the shelf does. - if (order === 'star') return [...rows].sort((a, b) => (b.star ?? 0) - (a.star ?? 0) || b.taken - a.taken); - return order === 'oldest' ? [...rows].reverse() : rows; - }, [shown, minStar, year, from, to, order]); + const getExt = (filename: string) => { + const idx = filename.lastIndexOf('.'); + return idx !== -1 ? filename.slice(idx + 1).toLowerCase() : ''; + }; + + const multiplier = sortDir === 'asc' ? 1 : -1; + + return [...rows].sort((a, b) => { + let diff = 0; + if (sortBy === 'taken') { + diff = a.taken - b.taken; + } else if (sortBy === 'mtime') { + diff = (a.mtime ?? a.taken) - (b.mtime ?? b.taken); + } else if (sortBy === 'name') { + diff = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + } else if (sortBy === 'type') { + const extA = getExt(a.name); + const extB = getExt(b.name); + diff = extA.localeCompare(extB) || a.name.localeCompare(b.name); + } else if (sortBy === 'star') { + diff = (a.star ?? 0) - (b.star ?? 0); + } + if (diff === 0) { + diff = a.taken - b.taken; + } + return diff * multiplier; + }); + }, [shown, minStar, year, from, to, sortBy, sortDir]); const handleSelectPhoto = useCallback( (photo: LibraryPhoto, e: React.MouseEvent) => { @@ -1578,15 +1601,27 @@ function Catalogue() {
+