5ff46116ce
HEAL came back a stain larger and softer than the stain it was asked to remove, and the model's half of it answered in a colour that appears nowhere near the repair. Three causes, all inside `engine/inpaint.ts`: - The fallback's reading ring was the hole dilated by two pixels whatever the brush, so the boundary of the fill was the dirt's own penumbra and the fill came back as the dirt. The pad is now proportional to the hole's own radius (`rimPad`), the fill is an inverse-square average of the clean ring relaxed into one field by 36 Jacobi rounds, and the hole's grain is read against a 2px box of the clean pixels and taken from the pixel it mirrors through its own middle. The texture half before this was a no-op — `detail = rgba[p] - fR[idx]` with `fR[idx]` the same pixel's own colour, so every donor handed over zero and the hole was the smooth field alone (15.6% of the ground's detail). Measured on a stain of radius 10 with a halo to 16 under a 12px brush (`probe-fallback.mjs`): the repair landed 22.3 levels below the ground it sits in, and comes back 0.06 off it. - The plate the model reads under the mask was the mean of every pixel of the crop, so the network met a hard step at the mask's own edge and answered in that colour. It is now the rim's own light spread inward (`cropPlate`), and the mask is downsampled by "any hole pixel in this cell" rather than by a corner sample. Step across the mask's edge: 69.3 levels before, 9.5 after. - The same repair came back different every time the reader zoomed out of it: the crop was sized from the stage's own pixels on the zoom ladder (`PREVIEW_QUANTUM`), so each zoom handed the model a different grid, and every grid invents its own pixels. The crop's box is now its share of the FRAME (`INPAINT_REF_LONG`, `exportEngine` 6e), so one spot is one grid at every zoom and at the export's own size. The patch is deliberately not baked into the photo: a recipe stays reusable on other frames and the repair stays a pass. ponytail: the pixels handed to the model are still read from whatever the stage rendered at, so the answer is the same repair to within the resampling of its input; a patch cache keyed on the frame's identity and the spots is the rung that makes it identical, and the one to take if a zoom still shows a repair move. VIGNETTING now runs both ways (-10..10): down darkens the four corners, up lifts the lens' own falloff out of them with a white ink — the same curve and the same weight. EV was defined and live on the phone but absent from the wide panel's own Light row, which is `DevelopPanels.tsx` and nothing else. `npx tsc --noEmit`, `vite build`, `heal-check`, `studio-drag` and the other suites pass. `studio-export-check` fails one assertion here on the code at HEAD as well — a frame left behind files its waiting look 9s after the pick — so it is not this change.
290 lines
8.4 KiB
TypeScript
290 lines
8.4 KiB
TypeScript
import { ColorAdjustments } from '../types';
|
||
import { EV_PER_UNIT } from './colorUtils';
|
||
|
||
// Static metadata for the continuous (slider) adjustments of the IQ/WB/FILTERS
|
||
// tabs — shared between AdjustmentPanel (chips + slider rows) and App (which
|
||
// needs the open row's bounds to drive drag-on-the-image adjustment). The get
|
||
// /set accessors are stateless so one table serves every render.
|
||
export interface ContinuousParamDef {
|
||
key: string;
|
||
label: string;
|
||
min: number;
|
||
max: number;
|
||
step: number;
|
||
defaultValue: number;
|
||
display: (v: number) => string;
|
||
get: (a: ColorAdjustments) => number;
|
||
set: (v: number) => Partial<ColorAdjustments>;
|
||
// PRO-only knob: the LITE build greys the chip out (PRO corner tag) and
|
||
// cannot open its row, so the value stays at its default there.
|
||
pro?: boolean;
|
||
}
|
||
|
||
const sign = (v: number) => (v > 0 ? `+${v}` : String(v));
|
||
const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`;
|
||
// The spec reads EXPOSURE to two decimals, so "+0.50" and "-0.25" and a plain
|
||
// "0.00" — the EV unit is already on the row's own label, not in its readout.
|
||
const twoStops = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||
|
||
// The bipolar develop knobs read -100..+100: the spec's own numbers (the panel
|
||
// draws every TONE and PRESENCE slider from -100 to 100, and the tone-mapping doc
|
||
// normalises that /100 on the way to its uniforms). The recipe keeps the ±10 unit
|
||
// every look has always been filed in, so the ×10 belongs to the slider and not
|
||
// to the store: a look saved, shared or shipped in DEFAULT_RECIPES before this
|
||
// still means exactly what it meant. A knob that is not bipolar — a temperature
|
||
// in kelvin, an amount that only goes up — keeps its own scale, because -100 of
|
||
// a percentage is nothing. EXPOSURE is the odd one out: its slider is in stops
|
||
// (-5..+5, two decimals, the spec's own numbers) while the store still holds the
|
||
// ±10 units EV_PER_UNIT converts, so its accessors translate instead of scaling
|
||
// and every recipe's brightness is untouched.
|
||
const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites', 'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']);
|
||
const deepen = (def: ContinuousParamDef): ContinuousParamDef =>
|
||
HUNDRED.has(def.key)
|
||
? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) }
|
||
: def;
|
||
|
||
export const PARAM_DEFS: {
|
||
iq: ContinuousParamDef[];
|
||
wb: ContinuousParamDef[];
|
||
filters: ContinuousParamDef[];
|
||
} = {
|
||
iq: ([
|
||
{
|
||
key: 'exposure',
|
||
label: 'EXPOSURE',
|
||
min: -5,
|
||
max: 5,
|
||
step: 0.01,
|
||
defaultValue: 0,
|
||
display: twoStops,
|
||
// Stops out, units in: the store has carried ±10 units since the first
|
||
// recipe, so the knob translates rather than replacing the scale. The
|
||
// /4 lands on a whole 0.04 for every step the slider can emit, which keeps
|
||
// the stored number readable in a recipe file.
|
||
get: (a) => (a.exposure ?? 0) * EV_PER_UNIT,
|
||
set: (v) => ({ exposure: Math.round((v / EV_PER_UNIT) * 1e4) / 1e4 }),
|
||
},
|
||
{
|
||
key: 'ev',
|
||
label: 'EV',
|
||
min: -3,
|
||
max: 3,
|
||
step: 0.1,
|
||
defaultValue: 0,
|
||
display: oneEv,
|
||
get: (a) => a.exposureCompensation ?? 0,
|
||
set: (v) => ({ exposureCompensation: v }),
|
||
},
|
||
{
|
||
key: 'contrast',
|
||
label: 'CONTRAST',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.contrast,
|
||
set: (v) => ({ contrast: v }),
|
||
},
|
||
{
|
||
key: 'color',
|
||
label: 'COLOR',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.saturation,
|
||
set: (v) => ({ saturation: v }),
|
||
},
|
||
{
|
||
key: 'vibrance',
|
||
label: 'VIBRANCE',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.vibrance ?? 0,
|
||
set: (v) => ({ vibrance: v }),
|
||
},
|
||
{
|
||
key: 'highlight',
|
||
label: 'HIGHLIGHTS',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.highlight ?? 0,
|
||
set: (v) => ({ highlight: v }),
|
||
},
|
||
{
|
||
key: 'shadow',
|
||
label: 'SHADOWS',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.shadow ?? 0,
|
||
set: (v) => ({ shadow: v }),
|
||
},
|
||
{
|
||
// The two ends of the same ramp the four tone sliders move together (see
|
||
// TONE_SKSL): WHITE is the knot on 1.00 and BLACK the one on 0.00, each
|
||
// pulled toward the middle as the knob comes down. Not a white-balance
|
||
// move any more, so they sit with the other two on the TONE panel. Same
|
||
// range as the CREATE form's rows, so a look round-trips.
|
||
key: 'whites',
|
||
label: 'WHITES',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.whites ?? 0,
|
||
set: (v) => ({ whites: v }),
|
||
},
|
||
{
|
||
key: 'blacks',
|
||
label: 'BLACKS',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.blacks ?? 0,
|
||
set: (v) => ({ blacks: v }),
|
||
},
|
||
] as ContinuousParamDef[]).map(deepen),
|
||
wb: ([
|
||
{
|
||
key: 'temperature',
|
||
label: 'COLOR TEMP',
|
||
min: 2500,
|
||
max: 10000,
|
||
step: 100,
|
||
defaultValue: 5500,
|
||
display: (v) => `${v}K`,
|
||
get: (a) => a.temperature,
|
||
set: (v) => ({ temperature: v }),
|
||
},
|
||
{
|
||
key: 'tint',
|
||
label: 'TINT',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.tint ?? 0,
|
||
set: (v) => ({ tint: v }),
|
||
},
|
||
] as ContinuousParamDef[]).map(deepen),
|
||
filters: ([
|
||
{
|
||
key: 'denoise',
|
||
label: 'NOISE REDUCTION',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.denoise,
|
||
set: (v) => ({ denoise: v }),
|
||
},
|
||
{
|
||
key: 'clarity',
|
||
label: 'CLARITY',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.clarity,
|
||
set: (v) => ({ clarity: v }),
|
||
},
|
||
{
|
||
// The haze the frame's own pixels carry (dark channel prior, toneShader's
|
||
// DEHAZE_SKSL): positive takes the scattered light out, negative puts it
|
||
// back — the same expression, with the transmission pushed above 1, which
|
||
// is the knob a photo shot through mist wants from this side. Its chip sits
|
||
// with the other spatial knob, CLARITY.
|
||
key: 'dehaze',
|
||
label: 'DEHAZE',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.dehaze ?? 0,
|
||
set: (v) => ({ dehaze: v }),
|
||
},
|
||
{
|
||
key: 'sharpening',
|
||
label: 'SHARPENING',
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.sharpening ?? 0,
|
||
set: (v) => ({ sharpening: v }),
|
||
},
|
||
{
|
||
key: 'grain',
|
||
label: 'MONOCHROME GRAIN',
|
||
min: 0,
|
||
max: 10,
|
||
// Half a step: the grain is a look, not a switch, and the AMOUNT chip in
|
||
// the GRAIN strip is what spends the half.
|
||
step: 0.5,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.grain,
|
||
set: (v) => ({ grain: v }),
|
||
},
|
||
{
|
||
// The SIZE knob: percent of the stock's own cell (grainShader.ts), so the
|
||
// same number means the same texture RELATIVE to the picture on both
|
||
// platforms and the readout beside it is the stock's, scaled.
|
||
key: 'grainSize',
|
||
label: 'GRAIN SIZE',
|
||
min: 50,
|
||
max: 200,
|
||
step: 5,
|
||
defaultValue: 100,
|
||
display: (v) => `${v}%`,
|
||
get: (a) => a.grainSize ?? 100,
|
||
set: (v) => ({ grainSize: v }),
|
||
},
|
||
{
|
||
key: 'hdf',
|
||
label: 'HDF EFFECT',
|
||
pro: true,
|
||
min: 0,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: String,
|
||
get: (a) => a.hdf ?? 0,
|
||
set: (v) => ({ hdf: v }),
|
||
},
|
||
{
|
||
key: 'vignette',
|
||
label: 'VIGNETTING',
|
||
// Both ways: down darkens the four corners (the Fuji control), up takes
|
||
// the lens' own falloff out of them — the repair a wide-open lens or a
|
||
// stacked hood leaves in the corners, which no other knob here can do.
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
defaultValue: 0,
|
||
display: sign,
|
||
get: (a) => a.vignette ?? 0,
|
||
set: (v) => ({ vignette: v }),
|
||
},
|
||
] as ContinuousParamDef[]).map(deepen),
|
||
};
|