feat(studio & library): sync Light & Color tab parameters (vibrance, highlight, shadow, white, black) to Gradient Mask & add multi-field sorting options
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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() {
|
||||
<div className="chip-row lib-filters">
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="lib-sort"
|
||||
data-key="lib-sort-by"
|
||||
aria-label={t('lib.sort')}
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value as 'newest' | 'oldest' | 'star')}
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
>
|
||||
<option value="newest">{t('lib.sortNewest')}</option>
|
||||
<option value="oldest">{t('lib.sortOldest')}</option>
|
||||
<option value="taken">{t('lib.sortDateTaken')}</option>
|
||||
<option value="mtime">{t('lib.sortDateEdited')}</option>
|
||||
<option value="type">{t('lib.sortFileType')}</option>
|
||||
<option value="name">{t('lib.sortName')}</option>
|
||||
<option value="star">{t('lib.sortStars')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="adm-input"
|
||||
data-key="lib-sort-dir"
|
||||
title={sortDir === 'desc' ? t('lib.sortDesc') : t('lib.sortAsc')}
|
||||
onClick={() => setSortDir((prev) => (prev === 'desc' ? 'asc' : 'desc'))}
|
||||
style={{ minWidth: '40px', padding: '0 8px', textAlign: 'center', cursor: 'pointer', fontWeight: 600 }}
|
||||
>
|
||||
{sortDir === 'desc' ? '⬇' : '⬆'}
|
||||
</button>
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="lib-filter-star"
|
||||
|
||||
@@ -274,6 +274,12 @@ export const en: Dict = {
|
||||
'lib.sortNewest': 'Newest first',
|
||||
'lib.sortOldest': 'Oldest first',
|
||||
'lib.sortStars': 'Most stars',
|
||||
'lib.sortDateTaken': 'Date taken',
|
||||
'lib.sortDateEdited': 'Date edited',
|
||||
'lib.sortFileType': 'File type',
|
||||
'lib.sortName': 'File name',
|
||||
'lib.sortAsc': 'Ascending',
|
||||
'lib.sortDesc': 'Descending',
|
||||
'lib.rating': 'RATING',
|
||||
'lib.ratingAll': 'Any rating',
|
||||
'lib.ratingAtLeast': '{n}★ and up',
|
||||
|
||||
@@ -287,6 +287,12 @@ export const vi = {
|
||||
'lib.sortNewest': 'Mới chụp trước',
|
||||
'lib.sortOldest': 'Cũ nhất trước',
|
||||
'lib.sortStars': 'Nhiều sao nhất',
|
||||
'lib.sortDateTaken': 'Ngày chụp',
|
||||
'lib.sortDateEdited': 'Ngày sửa/lưu',
|
||||
'lib.sortFileType': 'Loại tệp',
|
||||
'lib.sortName': 'Tên tệp',
|
||||
'lib.sortAsc': 'Tăng dần',
|
||||
'lib.sortDesc': 'Giảm dần',
|
||||
'lib.rating': 'XẾP HẠNG',
|
||||
'lib.ratingAll': 'Mọi mức sao',
|
||||
'lib.ratingAtLeast': 'Từ {n} sao',
|
||||
|
||||
Reference in New Issue
Block a user