studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
Two specs, one commit: the develop state becomes the panel column the Lightroom spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become shapes of the tone curve, the way the mapping spec measures them. The column. The left rail used to hand LIGHT a row of chips and nothing else; the four tone knobs were chips that opened a curve, and the rest of the develop state lived in the chip row's own vocabulary. `DevelopPanels` renders the five sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS — as an accordion, all open, and every parameter the recipe holds has a row in it with a `data-key` off the parameter name: slider, value readout, double-click to default. Sliders are always visible, so a knob is one drag away instead of two taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber, green through pink) so the direction is on the control. The PRO looks the spec marks stay in the list but locked, tagged PRO, and tapping one asks for PRO — they are shown, not hidden, and not silently dropped. WHITE and BLACK move out of the WB group. They were the temperature group's extremes, which is what a white balance control does — the toe and the shoulder of the same ramp — but the spec puts them with the tone knobs and gives them the two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights` and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives. A recipe written before this commit still reads: the keys are unchanged. The four knobs. The first pass of the mapping spec added a mask per zone onto the channel: `luma += knob * mask * intensity`, and the shader followed it. It is the wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the four masks are not a partition of the ramp. They sum to one at the ends and to zero at the midpoint, so an adjustment in the middle of a zone is applied where the mask is half and not at all where the mask has fallen to nothing, and the ramp inverts: with every knob at its stop the curve folds over itself, slope −5 at t=0.87, and the twin catches it as a non-monotone ramp. So each knob moves a knot on the curve instead, which is the reading the spec's own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50, HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a quarter of the range at that knot, so the range is 0.75..1.00 at the top and 0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`) by clamping each against its neighbour. Between knots the curve is a straight line, and 0.5 is untouched by every knob, so a knob at zero is the identity exactly rather than nearly, and any combination of the four is monotone. The mask sum survives where the spec is right about it: it hints the split between the two dark zones and the two light ones, nothing else. The hue is kept the way the spec keeps it: work in luma, then scale the chroma offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a saturated red stays the same red and only its brightness moves. The ratio is clamped to 0.55..1.35 because at luma near zero the division is the whole highlight of the picture on one code value. Verified: - `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader — four masks, four anchors, the four `mix` lines — and asserts the constructions it replaced are gone, then drives a twin of the ramp in JS: the masks do not overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162 combinations of the four knobs, every combination is monotone, the amplitude at each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded case from the additive build is in the harness as a regression. - `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`, `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check` and `white-level-check` all pass against the bundle — the catalogue, the RAW path, auto tone and the white level are untouched by the panel move. - Driven in a real browser (`tone-live-check.mjs`, Chromium against `vite preview`, a P1010256.JPG in the source control, mean luma of the preview canvas read before and after each knob): neutral 184.25, WHITE +1 187.35, BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80. Every knob moves the picture the way the spec says it should and none of them moves it much — a stop of a knob is a quarter of a zone, not a level. - The same run asserts the built DOM: five panels, the 23 `data-key` rows, `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and `dev-shadow` together in TONE, the gradient classes on the two white balance sliders, and the chip slots the panel is handed. The only failed request is `/api/events`, which is the backend this preview does not run. ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a per-channel rolloff in linear light — is gone, deleted rather than ported. The additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends, and the per-channel rolloff is a second knob for the same picture. Bring it back as its own parameter if a frame ever clips badly enough to need it. Also dropped: DR used to ride along as two additive terms. That is where the fold at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the ramp past its own end. It shifts the knots now, which is what the film sims always meant by it, and the numbers in the sims were kept and their meaning recommented (classic-chrome toe 0.22, head 0.7375, etc.). Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
+66
-10
@@ -50,6 +50,7 @@ import { HEAL_DEFAULT_R } from '../shared/utils/heal';
|
||||
import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic';
|
||||
import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask';
|
||||
import { readHistogram, autoExposureStops, autoTone, autoWhiteBalance } from './ui/Histogram';
|
||||
import { DevelopPanels } from './ui/DevelopPanels';
|
||||
import type { MsgKey } from './i18n/vi';
|
||||
|
||||
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
|
||||
@@ -2755,16 +2756,15 @@ export function Workspace() {
|
||||
return rows;
|
||||
}
|
||||
case 'light':
|
||||
// LIGHT is the one tab whose knobs are not chips: the sidebar
|
||||
// (DevelopPanels) puts every one of them on screen at once, so only the
|
||||
// two things that are NOT knobs stay here — AUTO, which is an action
|
||||
// (see autoTune), and TONE CURVE, which opens the graph on the photo
|
||||
// (ToneCurvePanel) rather than a ruler in the last column. It glows amber
|
||||
// once the graph is off the diagonal, which is the only place the curve
|
||||
// is reported.
|
||||
return [
|
||||
// AUTO rides at the head of the strip because it is the one chip here
|
||||
// that is an ACTION rather than a knob or a look (see autoTune).
|
||||
{ key: 'auto', label: 'AUTO', onClick: () => void autoTune() },
|
||||
...paramChips(PARAM_DEFS.iq),
|
||||
groupChip('dr'),
|
||||
// TONE CURVE is not a row of sliders: it opens the graph on the photo
|
||||
// (ToneCurvePanel), so the chip toggles that overlay rather than a
|
||||
// ruler in the last column. It glows amber once the graph is off the
|
||||
// diagonal, which is the only place the curve is reported.
|
||||
{
|
||||
key: 'curve',
|
||||
label: 'TONE CURVE',
|
||||
@@ -3032,6 +3032,41 @@ export function Workspace() {
|
||||
})),
|
||||
];
|
||||
|
||||
// The studio sidebar's three slots (DevelopPanels): the picks that name a
|
||||
// whole look rather than a number, so they stay strips of their own inside the
|
||||
// panel that owns them — SIM and D.RANGE with the tone controls, the WB
|
||||
// presets with the two WB tracks, the two Color Chromes at the foot of the
|
||||
// effects column. Every one of them is the strip that already existed, so a
|
||||
// pick made here is the pick made on the tab it came from.
|
||||
const developSlots = {
|
||||
profile: (
|
||||
<>
|
||||
<ChipRow chips={choiceChips('sim', groupDefs.style.options, groupDefs.style.value, groupDefs.style.onPick, simPreview)} />
|
||||
<ChipRow
|
||||
chips={choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
// COLOR TEMP is left out: the ruler is the panel's own TEMPERATURE row, so a
|
||||
// chip that opened the same ruler would be a second way to the same knob.
|
||||
wb: (
|
||||
<ChipRow
|
||||
chips={choiceChips('temp', WB_PRESETS.map((p) => ({ v: p.key, d: p.label })), wbValue(), (v) => {
|
||||
remember();
|
||||
setWbChoice(v);
|
||||
const p = WB_PRESETS.find((w) => w.key === v) ?? WB_PRESETS[0];
|
||||
setAdjustment({ temperature: p.kelvin, tint: p.tint });
|
||||
})}
|
||||
/>
|
||||
),
|
||||
effects: (
|
||||
<>
|
||||
<ChipRow chips={choiceChips('cx', CHROME_OPTIONS, recipe.adjustments.colorChrome || 'none', groupDefs.cx.onPick)} />
|
||||
<ChipRow chips={choiceChips('cxb', CHROME_OPTIONS, recipe.adjustments.colorChromeBlue || 'none', groupDefs.cxb.onPick)} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the
|
||||
// open chip's own panel, the open group's options, the open ruler. Each level
|
||||
// is a column of its own, so a child never hides the column it came from.
|
||||
@@ -3115,7 +3150,12 @@ export function Workspace() {
|
||||
|
||||
<div className="chips">
|
||||
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
|
||||
<div className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}`} data-col="main">
|
||||
<div
|
||||
className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}${
|
||||
tab === 'light' ? ' col-dev' : ''
|
||||
}`}
|
||||
data-col="main"
|
||||
>
|
||||
{tab === 'create' ? (
|
||||
<RecipeCreatePanel
|
||||
currentBase={recipe.baseFilter}
|
||||
@@ -3172,7 +3212,23 @@ export function Workspace() {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ChipRow chips={chips} />
|
||||
<>
|
||||
{/* LIGHT's column is the sidebar itself: the two chips that are
|
||||
not knobs, then every knob of the tab in one stack, all of
|
||||
them open at once. */}
|
||||
{tab === 'light' ? <ChipRow chips={chips} /> : null}
|
||||
{tab === 'light' ? (
|
||||
<DevelopPanels
|
||||
adj={recipe.adjustments}
|
||||
set={setAdjustmentOnce}
|
||||
pro={pro}
|
||||
onPro={promptPro}
|
||||
slots={developSlots}
|
||||
/>
|
||||
) : (
|
||||
<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
|
||||
|
||||
@@ -1274,6 +1274,48 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.stats-track i { display: block; height: 100%; border-radius: 4px; background: var(--accent); }
|
||||
.stats-n { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* --- studio sidebar (DevelopPanels) ------------------------------------- */
|
||||
/* Lightroom's right column: every panel's knobs are on screen at once, each
|
||||
panel a disclosure that folds its own rows away. Colour comes from the tokens
|
||||
only, so a theme or accent change lands here with no rules of its own. */
|
||||
/* The sidebar (DevelopPanels) draws rows, not chips, so its column is the wide
|
||||
one: the same width CREATE RECIPES takes, and the chips that are not knobs
|
||||
(AUTO, TONE CURVE) stack over it, wrapped in the column's own .chip-row. */
|
||||
.col-dev { width: 320px; }
|
||||
.dev-panels { display: flex; flex-direction: column; gap: 10px; }
|
||||
.dev-panel { border-top: 1px solid var(--border-soft); padding-top: 8px; }
|
||||
.dev-panel-head {
|
||||
display: flex; justify-content: space-between;
|
||||
width: 100%; padding: 0; border: 0; background: transparent;
|
||||
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text); cursor: pointer;
|
||||
}
|
||||
.dev-panel-head:hover { color: var(--accent); }
|
||||
.dev-panel-body { display: flex; flex-direction: column; gap: 10px; padding-top: 8px; }
|
||||
/* A row that cannot be edited at all: a PRO knob on the LITE build. It reads as
|
||||
a row, not as a button — the tag is what says the row is not a slider. */
|
||||
.dev-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.dev-row.locked {
|
||||
flex-direction: row; align-items: center; gap: 6px;
|
||||
width: 100%; padding: 0; border: 0; background: transparent;
|
||||
text-align: left; opacity: 0.5; cursor: not-allowed;
|
||||
}
|
||||
.dev-pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
|
||||
/* WB's two knobs: the track is the hue the value moves towards, the way
|
||||
Lightroom's temperature and tint sliders are painted. Everything else keeps
|
||||
the plain input's accent color. */
|
||||
.track-temp, .track-tint {
|
||||
height: 4px; border-radius: 999px;
|
||||
appearance: none; -webkit-appearance: none;
|
||||
}
|
||||
.track-temp { background: linear-gradient(90deg, #3f6fd8, #b9c2cc 50%, #e8a33d); }
|
||||
.track-tint { background: linear-gradient(90deg, #3f9d55, #b9c2cc 50%, #c04ec4); }
|
||||
.track-temp::-webkit-slider-thumb, .track-tint::-webkit-slider-thumb {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: var(--text); border: none; cursor: pointer;
|
||||
}
|
||||
|
||||
/* --- responsive --------------------------------------------------------- */
|
||||
@media (max-width: 860px) {
|
||||
.workspace { flex-direction: column; }
|
||||
|
||||
@@ -162,6 +162,9 @@ export function MiniSlider({
|
||||
step = 1,
|
||||
prefix = 'hsl-knob',
|
||||
format,
|
||||
defaultValue = 0,
|
||||
track,
|
||||
dataKey,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
@@ -172,6 +175,16 @@ export function MiniSlider({
|
||||
step?: number;
|
||||
prefix?: string;
|
||||
format?: (value: number) => string;
|
||||
// The value the readout stays grey at. 0 is the mixer's own default, but the
|
||||
// studio's WB rows are not the only knobs here: a temperature's default is
|
||||
// 5500K and a grain size's is 100, so the caller that knows says so.
|
||||
defaultValue?: number;
|
||||
// A coloured track for the two WB knobs (CSS track-temp / track-tint), whose
|
||||
// travel is a hue rather than a number.
|
||||
track?: 'temp' | 'tint';
|
||||
// Replaces the `<prefix>-<label>` key for a caller that addresses a row by the
|
||||
// parameter's own key (the sidebar's dev-<key>) instead of its label.
|
||||
dataKey?: string;
|
||||
onChange: (value: number) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
@@ -194,10 +207,10 @@ export function MiniSlider({
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [min, max, step]);
|
||||
return (
|
||||
<div className="mini-slider" ref={ref} data-key={`${prefix}-${label}`}>
|
||||
<div className="mini-slider" ref={ref} data-key={dataKey ?? `${prefix}-${label}`}>
|
||||
<div className="mini-head">
|
||||
<span>{label}</span>
|
||||
<b className={value !== 0 ? 'amber' : ''} data-key={`${prefix}-${label}-value`}>
|
||||
<b className={value !== defaultValue ? 'amber' : ''} data-key={`${prefix}-${label}-value`}>
|
||||
{format ? format(value) : value > 0 ? `+${value}` : String(value)}
|
||||
</b>
|
||||
</div>
|
||||
@@ -208,6 +221,7 @@ export function MiniSlider({
|
||||
step={step}
|
||||
value={value}
|
||||
aria-label={label}
|
||||
className={track ? `track-${track}` : undefined}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onDoubleClick={onReset}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ColorAdjustments } from '../../shared/types';
|
||||
import { PARAM_DEFS } from '../../shared/utils/paramDefs';
|
||||
import { MiniSlider } from './ChipColumn';
|
||||
|
||||
// One lookup for every row. The defs are split over the three tabs' tables and
|
||||
// the sidebar reads them all at once, so the map is built once here; a key that
|
||||
// is not in it draws no row rather than a broken one.
|
||||
const DEF_BY_KEY = new Map(
|
||||
[...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters].map((def) => [def.key, def])
|
||||
);
|
||||
|
||||
// The stack, top to bottom, and the rows each panel owns. The two WB tracks are
|
||||
// the only rows whose travel is a colour, so the class each input wears is named
|
||||
// here with them; anything else is the bare slider.
|
||||
const TRACK: Record<string, 'temp' | 'tint' | undefined> = { temperature: 'temp', tint: 'tint' };
|
||||
|
||||
const PANELS: {
|
||||
slug: string;
|
||||
title: string;
|
||||
rows: string[];
|
||||
// A slot rides inside its panel, where the parent's own select belongs in the
|
||||
// reading order — before the panel's knobs except in DETAIL & EFFECTS, whose
|
||||
// COLOR CHROME selects close the column.
|
||||
slot?: 'profile' | 'wb' | 'effects';
|
||||
slotFirst?: boolean;
|
||||
}[] = [
|
||||
{ slug: 'profile', title: 'PROFILE', rows: ['ev'], slot: 'profile', slotFirst: true },
|
||||
{ slug: 'wb', title: 'WB', rows: ['temperature', 'tint'], slot: 'wb', slotFirst: true },
|
||||
{ slug: 'tone', title: 'TONE', rows: ['exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'] },
|
||||
{ slug: 'presence', title: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] },
|
||||
{
|
||||
slug: 'effects',
|
||||
title: 'DETAIL & EFFECTS',
|
||||
rows: ['denoise', 'sharpening', 'vignette', 'hdf', 'grain', 'grainSize'],
|
||||
slot: 'effects',
|
||||
},
|
||||
];
|
||||
|
||||
function DevRow({
|
||||
paramKey,
|
||||
adj,
|
||||
set,
|
||||
pro,
|
||||
onPro,
|
||||
}: {
|
||||
paramKey: string;
|
||||
adj: ColorAdjustments;
|
||||
set: (patch: Partial<ColorAdjustments>) => void;
|
||||
pro: boolean;
|
||||
onPro?: () => void;
|
||||
}) {
|
||||
const def = DEF_BY_KEY.get(paramKey);
|
||||
if (!def) return null;
|
||||
// A PRO knob on the LITE build is a label with nothing under it: the value
|
||||
// stays at its default, and the row's own click is handed to the parent's
|
||||
// upgrade path instead of a slider that would move nothing on the photo.
|
||||
if (def.pro && !pro) {
|
||||
return (
|
||||
<button type="button" className="dev-row locked" data-key={`dev-${paramKey}`} onClick={() => onPro?.()}>
|
||||
{def.label}
|
||||
<span className="dev-pro">PRO</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MiniSlider
|
||||
label={def.label}
|
||||
value={def.get(adj)}
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
step={def.step}
|
||||
defaultValue={def.defaultValue}
|
||||
track={TRACK[paramKey]}
|
||||
format={def.display}
|
||||
prefix="dev"
|
||||
dataKey={`dev-${paramKey}`}
|
||||
onChange={(v) => set(def.set(v))}
|
||||
onReset={() => set(def.set(def.defaultValue))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The studio's right column: every panel's knobs on screen at once, each panel a
|
||||
// disclosure that folds its own rows away. One undo step is the parent's `set`,
|
||||
// so nothing here holds state but which panels are open.
|
||||
export function DevelopPanels({
|
||||
adj,
|
||||
set,
|
||||
pro = false,
|
||||
onPro,
|
||||
slots,
|
||||
}: {
|
||||
adj: ColorAdjustments;
|
||||
set: (patch: Partial<ColorAdjustments>) => void;
|
||||
pro?: boolean;
|
||||
onPro?: () => void;
|
||||
slots?: { profile?: ReactNode; wb?: ReactNode; effects?: ReactNode };
|
||||
}) {
|
||||
const [open, setOpen] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(PANELS.map((panel) => [panel.slug, true]))
|
||||
);
|
||||
return (
|
||||
<div className="dev-panels">
|
||||
{PANELS.map((panel) => {
|
||||
const isOpen = open[panel.slug];
|
||||
const slot = panel.slot ? slots?.[panel.slot] : null;
|
||||
return (
|
||||
<section key={panel.slug} className="dev-panel" data-key={`dev-panel-${panel.slug}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="dev-panel-head"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setOpen((o) => ({ ...o, [panel.slug]: !isOpen }))}
|
||||
>
|
||||
{panel.title}
|
||||
<span aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="dev-panel-body">
|
||||
{panel.slotFirst ? slot : null}
|
||||
{panel.rows.map((key) => (
|
||||
<DevRow key={key} paramKey={key} adj={adj} set={set} pro={pro} onPro={onPro} />
|
||||
))}
|
||||
{panel.slotFirst ? null : slot}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user