fix(studio): stroke clustering for HEAL, mobile WebGL inpaint stability, and reorganize LIGHT panels with HSL

This commit is contained in:
2026-10-14 12:57:31 +07:00
parent c1d098e4e2
commit 70ed61b8de
6 changed files with 333 additions and 222 deletions
+175 -85
View File
@@ -3616,64 +3616,6 @@ export function Workspace() {
{ key: 'tools-rule', divider: true, label: '', onClick: () => {} },
...shapeChips,
];
case 'hsl': {
// The mixer is PRO: the picker, the eight bands and the three
// image-wide knobs all come with the account, so a guest is offered the
// reason rather than a wall of dead chips.
if (!pro) return [{ key: 'hsl-pro', label: t('pro.hslChip'), pro: true, onClick: promptPro }];
// 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.
// Those three are the image's own move, not the band's: the band chips
// pick which colour the panel on the photo edits, while HUE/SAT/LUM
// below the divider move every hue in the frame at once.
const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0];
const knob = (key: string, label: string, i: number): ChipDef => ({
key,
label,
value: own[i] > 0 ? `+${own[i]}` : String(own[i]),
active: openParam === key,
amberValue: openParam !== key && own[i] !== 0,
onClick: () => toggleParam(key),
});
return [
{ key: 'hsl-pick', label: '⌖ PICK', active: picking, onClick: () => { setBrushTool(null); setMaskTool(null); setPicking((v) => !v); } },
// PICK is not a band: it reads a colour off the photo to aim the
// mixer, and every chip under the rule edits what it read, so the
// rule is where the row stops being the tool and starts being the
// colour.
{ key: 'hsl-pick-rule', divider: true, label: '', onClick: () => {} },
...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,
// The chip names a colour, so it shows the colour it names.
color: hslToHex(b.hue, 70, 50),
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
// A band chip aims the mixer AND opens its panel, the same panel
// the eyedropper opens: the colour it starts from is the colour
// the chip is named after, and this band's knobs push it from
// there. A pick already on the photo keeps its spot — only a
// mixer with nowhere to hang takes the middle of the frame.
onClick: () => {
setHslBand(b.id);
setSample(hexToRgb(hslToHex(b.hue, 70, 50)));
setPickedAt((at) => at ?? { fx: 0.5, fy: 0.5 });
},
};
}),
// The divider keeps the three image-wide sliders out of the colour
// row: the rule stands where the colour ends and the knobs that move
// every colour at once begin, and IMAGE names what they move.
{ key: 'hsl-image-rule', divider: true, label: '', onClick: () => {} },
{ key: 'hsl-image', label: 'IMAGE', disabled: true, onClick: () => {} },
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.
@@ -3997,6 +3939,180 @@ export function Workspace() {
<ChipRow chips={choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick)} />
</div>
),
hsl: (
<div className="dev-box" data-key="dev-box-hsl" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '8px' }}>
<button
type="button"
className={`chip${picking ? ' on' : ''}`}
onClick={() => {
setBrushTool(null);
setMaskTool(null);
setPicking((v) => !v);
}}
>
<span className="glyph">⌖</span> PICK
</button>
{sample && picked ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'var(--text-dim)' }}>
<span
style={{
display: 'inline-block',
width: '12px',
height: '12px',
borderRadius: '50%',
background: `rgb(${sample.r}, ${sample.g}, ${sample.b})`,
border: '1px solid var(--border)',
}}
/>
<span>R {sample.r} · G {sample.g} · B {sample.b}</span>
</div>
) : null}
</div>
<div>
<span className="dev-box-title" style={{ marginBottom: '6px', display: 'block' }}>COLOR BANDS</span>
<ChipRow
grid
chips={HSL_BANDS.map((b) => {
const band = recipe.adjustments.hslBands?.[b.id];
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
return {
key: `dev-hsl-${b.id}`,
label: b.label,
color: hslToHex(b.hue, 70, 50),
active: hslBand === b.id,
amberValue: hslBand !== b.id && moved,
onClick: () => {
setHslBand(b.id);
setSample(hexToRgb(hslToHex(b.hue, 70, 50)));
setPickedAt((at) => at ?? { fx: 0.5, fy: 0.5 });
},
};
})}
/>
</div>
{hslBand && (
<div className="dev-subgroup" style={{ marginTop: '4px' }}>
<div className="dev-subgroup-title" style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>BAND: {HSL_BANDS.find((b) => b.id === hslBand)?.label}</span>
{(recipe.adjustments.hslBands?.[hslBand]?.[0] || recipe.adjustments.hslBands?.[hslBand]?.[1] || recipe.adjustments.hslBands?.[hslBand]?.[2]) ? (
<button
type="button"
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '10px' }}
onClick={() => {
remember();
const bands = { ...(recipe.adjustments.hslBands ?? {}) };
delete bands[hslBand];
setAdjustment({ hslBands: bands });
}}
>
RESET
</button>
) : null}
</div>
{(() => {
const bandVals = recipe.adjustments.hslBands?.[hslBand] ?? [0, 0, 0];
const updateBand = (idx: number, val: number) => {
remember();
const cur = [...(recipe.adjustments.hslBands?.[hslBand] ?? [0, 0, 0])] as [number, number, number];
cur[idx] = val;
setAdjustment({
hslBands: {
...(recipe.adjustments.hslBands ?? {}),
[hslBand]: cur,
},
});
};
return (
<>
<div className="dev-row">
<span className="dev-row-label">Hue</span>
<input
type="range"
min={-30}
max={30}
value={bandVals[0]}
onChange={(e) => updateBand(0, Number(e.target.value))}
/>
<span className="dev-row-value">{bandVals[0] > 0 ? `+${bandVals[0]}` : bandVals[0]}</span>
</div>
<div className="dev-row">
<span className="dev-row-label">Saturation</span>
<input
type="range"
min={-100}
max={100}
value={bandVals[1]}
onChange={(e) => updateBand(1, Number(e.target.value))}
/>
<span className="dev-row-value">{bandVals[1] > 0 ? `+${bandVals[1]}` : bandVals[1]}</span>
</div>
<div className="dev-row">
<span className="dev-row-label">Luminance</span>
<input
type="range"
min={-100}
max={100}
value={bandVals[2]}
onChange={(e) => updateBand(2, Number(e.target.value))}
/>
<span className="dev-row-value">{bandVals[2] > 0 ? `+${bandVals[2]}` : bandVals[2]}</span>
</div>
</>
);
})()}
</div>
)}
<div className="dev-subgroup" style={{ marginTop: '4px' }}>
<div className="dev-subgroup-title">IMAGE MASTER</div>
<div className="dev-row">
<span className="dev-row-label">Hue</span>
<input
type="range"
min={-180}
max={180}
value={recipe.adjustments.hslHue ?? 0}
onChange={(e) => {
remember();
setAdjustment({ hslHue: Number(e.target.value) });
}}
/>
<span className="dev-row-value">{(recipe.adjustments.hslHue ?? 0) > 0 ? `+${recipe.adjustments.hslHue}` : recipe.adjustments.hslHue ?? 0}</span>
</div>
<div className="dev-row">
<span className="dev-row-label">Saturation</span>
<input
type="range"
min={-100}
max={100}
value={recipe.adjustments.hslSat ?? 0}
onChange={(e) => {
remember();
setAdjustment({ hslSat: Number(e.target.value) });
}}
/>
<span className="dev-row-value">{(recipe.adjustments.hslSat ?? 0) > 0 ? `+${recipe.adjustments.hslSat}` : recipe.adjustments.hslSat ?? 0}</span>
</div>
<div className="dev-row">
<span className="dev-row-label">Luminance</span>
<input
type="range"
min={-100}
max={100}
value={recipe.adjustments.hslLum ?? 0}
onChange={(e) => {
remember();
setAdjustment({ hslLum: Number(e.target.value) });
}}
/>
<span className="dev-row-value">{(recipe.adjustments.hslLum ?? 0) > 0 ? `+${recipe.adjustments.hslLum}` : recipe.adjustments.hslLum ?? 0}</span>
</div>
</div>
</div>
),
};
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the
@@ -4290,33 +4406,7 @@ export function Workspace() {
their column is the visitor's to widen (see ColGrip). */}
{listTab ? <ColGrip col="main" /> : null}
{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' && pro ? (
<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">