web: give a mask its knobs as rulers and let the ramp be turned

A mask's knobs were a card of small sliders side by side, three abreast under the
colour they were moving, which is the shape the mixer needs because its whole
point is reading three bands at once. A mask has nothing to read against: its
knobs are a list, and the strip beside the photo has always had the shape for a
list — the ruler, one parameter to a row, with its own name, its own value and
the width to aim with. So the card goes and the rows come: EXPOSURE, CONTRAST,
SATURATION, and FEATHER below them for the shape that fades over one, each the
same ruler the FX panel opens, stacked in the mask's own column next to the chip
that says which shape is chosen.

Only the ramp could be moved and not aimed. An ellipse is stored as an angle, so
turning it is writing a new one; a line is stored as the two points it falls
between, and turning one is moving both of them about their own middle by the
same turn — which is the point of doing it that way: the length survives, the
middle survives, and only the direction the gradient falls in changes. The turn
handle a chosen ramp now wears hangs clear of its middle along the ramp's own
normal, so it never sits on the pin the shape is dragged by, and the angle the
hand asks for is measured in the photo's own pixels, so a quarter turn of the
hand is a quarter turn of the ramp however the photo is shaped or zoomed. Both
handles for both kinds now come out of one list and one map, which is how a ramp
and an ellipse ended up wearing the same class, the round one that says "this
turns me".

ponytail: the ruler row takes no data-key of its own — the parameter's key is
already on the chip that opened it, and the panel's fourth column opens a ruler
under the same name a strip chip answers to, so a second element carrying it
would make an existing selector ambiguous. The mask's rows are found by the label
they print; if a probe ever needs to hook a row directly, the key belongs on the
row and the panel's ruler needs its own name first. Highlights/Shadows and
per-mask invert and range are still out, as before.

Verified: tsc clean; mask-probe 42/0 on the dev server and again on 8090 — the
three rows share an edge and stack, a linear mask carries no feather row, a
radial one carries it fourth, and dragging the ramp's turn handle stands the
gradient up the photo: 61 at the top and 244 at the bottom where it was 61 -> 244
across — brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/
award-column/otp-code/tone-curve all ALL PASS, backend 180/0.
This commit is contained in:
2026-09-24 10:54:56 +07:00
parent b568fa3fdc
commit b7ff298789
3 changed files with 145 additions and 88 deletions
+69 -43
View File
@@ -2233,6 +2233,69 @@ export function Workspace() {
|| (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]);
}, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]);
// The chosen mask's knobs, as the app's own rulers: one parameter to a row,
// the same row every other number in the app is edited with. EXPOSURE is the
// spec's EV (-5..+5, its own `pow(2.0, e)`); CONTRAST and SATURATION are the
// app's -10..+10, which the shader reads as the spec's -1..+1; and a radial
// mask adds the feather it fades over — the percentage of its own axis the
// alpha holds full before it dies at the rim.
const maskRulers: SliderDef[] = !selMask
? []
: [
{
key: 'mask-exposure',
label: 'EXPOSURE',
value: selMask.exposure,
min: -MASK_EXPOSURE_MAX,
max: MASK_EXPOSURE_MAX,
step: 0.1,
display: `${selMask.exposure > 0 ? '+' : ''}${selMask.exposure.toFixed(1)}`,
edited: selMask.exposure !== 0,
onChange: (v) => setMaskKnob({ exposure: v }),
onReset: () => setMaskKnob({ exposure: 0 }),
},
{
key: 'mask-contrast',
label: 'CONTRAST',
value: selMask.contrast,
min: -10,
max: 10,
step: 1,
display: selMask.contrast > 0 ? `+${selMask.contrast}` : String(selMask.contrast),
edited: selMask.contrast !== 0,
onChange: (v) => setMaskKnob({ contrast: Math.round(v) }),
onReset: () => setMaskKnob({ contrast: 0 }),
},
{
key: 'mask-saturation',
label: 'SATURATION',
value: selMask.saturation,
min: -10,
max: 10,
step: 1,
display: selMask.saturation > 0 ? `+${selMask.saturation}` : String(selMask.saturation),
edited: selMask.saturation !== 0,
onChange: (v) => setMaskKnob({ saturation: Math.round(v) }),
onReset: () => setMaskKnob({ saturation: 0 }),
},
...(selMask.kind === 'radial'
? [
{
key: 'mask-feather',
label: 'FEATHER',
value: Math.round(selMask.feather * 100),
min: 0,
max: 100,
step: 5,
display: `${Math.round(selMask.feather * 100)}%`,
edited: selMask.feather !== MASK_DEFAULT_FEATHER,
onChange: (v: number) => setMaskKnob({ feather: v / 100 }),
onReset: () => setMaskKnob({ feather: MASK_DEFAULT_FEATHER }),
},
]
: []),
];
const resetChip = (): ChipDef => ({
key: 'reset-all',
label: resetDirty ? 'RESET *' : 'RESET',
@@ -2768,9 +2831,9 @@ export function Workspace() {
</div>
{/* column 2 — FX's GRADIENT MASK, while one is chosen: the shape's own
name, the way to take it off the photo, and the three knobs it
grades with. A radial mask adds the feather it fades over, since
that is the number its rim is made of. */}
name, the way to take it off the photo, and its knobs as the app's
own rulers — one parameter to a row. A radial mask adds the feather
it fades over, since that is the number its rim is made of. */}
{selMask ? (
<div className="col col-sub strip" data-col="mask">
<ChipRow
@@ -2779,46 +2842,9 @@ export function Workspace() {
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
]}
/>
<div className="hsl-card-knobs">
<MiniSlider
label="EXP"
value={selMask.exposure}
min={-MASK_EXPOSURE_MAX}
max={MASK_EXPOSURE_MAX}
step={0.1}
prefix="mask-knob"
format={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
onChange={(v) => setMaskKnob({ exposure: v })}
onReset={() => setMaskKnob({ exposure: 0 })}
/>
<MiniSlider
label="CON"
value={selMask.contrast}
prefix="mask-knob"
onChange={(v) => setMaskKnob({ contrast: Math.round(v) })}
onReset={() => setMaskKnob({ contrast: 0 })}
/>
<MiniSlider
label="SAT"
value={selMask.saturation}
prefix="mask-knob"
onChange={(v) => setMaskKnob({ saturation: Math.round(v) })}
onReset={() => setMaskKnob({ saturation: 0 })}
/>
{selMask.kind === 'radial' ? (
<MiniSlider
label="FEA"
value={Math.round(selMask.feather * 100)}
min={0}
max={100}
step={5}
prefix="mask-knob"
format={(v) => `${v}%`}
onChange={(v) => setMaskKnob({ feather: v / 100 })}
onReset={() => setMaskKnob({ feather: MASK_DEFAULT_FEATHER })}
/>
) : null}
</div>
{maskRulers.map((def) => (
<SliderRow key={def.key} slider={def} />
))}
</div>
) : null}