diff --git a/src/utils/colorUtils.ts b/src/utils/colorUtils.ts index e8118da..4ad087c 100644 --- a/src/utils/colorUtils.ts +++ b/src/utils/colorUtils.ts @@ -150,6 +150,18 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s }; } +// Brightness a stock carries BY ITSELF, in the EXPOSURE slider's own units. +// It is part of the look, not a knob the user set, so it never reaches the +// panel (a sim's adjustments stay neutral — see FILM_SIMS in defaultRecipes) +// and the slider reads 0 while the render is already lifted. Keyed by stock +// because that is what the renderers know: `baseFilter` travels with every +// recipe, an armed sim id does not. ponytail: one entry, one stock; if two +// sims ever need to share a matrix with different brightness, the key has to +// move to the sim id. +const STOCK_EXPOSURE: Partial> = { + 'leica-vivid': 2, // LC STREETLIFE VIVID reads bright straight off the shutter +}; + // Generate a 4x5 ColorMatrix (array of 20 floats) based on base style and // adjustments. Tone-domain knobs (dynamicRange/highlight/shadow) and the // exposure-compensation EV gain are intentionally NOT part of the matrix — @@ -157,7 +169,9 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number } // and EV is applied either as hardware AE bias (camera) or by the caller via // applyExposureGain (library), never doubled. export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] { - const exposure = adj.exposure; + // The stock's own brightness rides with the slider: same maths, same band + // (it is a property of the look, not of this photo's edit). + const exposure = adj.exposure + (STOCK_EXPOSURE[baseFilter] ?? 0); const contrast = adj.contrast; const saturation = adj.saturation; const temperature = adj.temperature; @@ -270,9 +284,10 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments // digital era: ~20% of chroma gain (still well under Velvia's 39%, so it // sparkles instead of shouting) on the same warm R>G>B bias CLASSIC has, so // VIVID is a brighter read of one look rather than a second, neutral one. - // No positive lift here: the brightness is the sim's own exposure value - // (defaultRecipes), which keeps the matrix inside 0..1 and pure white out of - // the clip — white lands at most at 1.000 on the red row. + // No positive lift here either, so the matrix stays inside 0..1 and pure + // white stays out of the clip — white lands at most at 1.000 on the red row. + // The brightness this look is known for is STOCK_EXPOSURE below: a fixed + // exposure move of the stock itself, never a chroma trick. matrix = [ 1.125, -0.100, -0.010, 0, -0.015, -0.055, 1.110, -0.055, 0, -0.005, diff --git a/src/utils/defaultRecipes.ts b/src/utils/defaultRecipes.ts index 636009b..3e0bd20 100644 --- a/src/utils/defaultRecipes.ts +++ b/src/utils/defaultRecipes.ts @@ -29,13 +29,37 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = { exposureCompensation: 0, }; -// Standard Fuji film simulations. These are the fixed "look" presets shown at -// the front of the PRESETS chip row: tapping one applies the simulation — its -// baseFilter colour matrix plus whatever the stock itself carries outside the -// matrix (its FILM_TONE curve, and a grain amount when the grain IS the look, -// as on Acros). Every other knob still resets to its neutral default (0 / -// auto), so a sim is a clean starting point, never a baked recipe; any further -// look (DR, CC, contrast, more grain) is added by the user afterwards. +// Standard film simulations. These are the fixed "look" presets shown at the +// front of the PRESETS chip row. +// +// RULE — a sim carries COLOUR AND TONE ONLY, never an effect. Its whole look +// must come from its baseFilter colour matrix plus its FILM_TONE curve +// (toneShader.ts): the two things that *define* the stock. No grain, no +// clarity, no exposure/saturation/shadow/highlight move — those are edits the +// user makes afterwards, never something a sim bakes in. That is why every sim +// below shares the neutral DEFAULT_ADJUSTMENTS verbatim; if a look ever needs +// more, put it in the matrix or the tone curve instead. +// +// The one exception is a stock that is simply BRIGHTER: that is a fixed +// exposure move of the look itself, so it lives in STOCK_EXPOSURE +// (colorUtils.ts), keyed by baseFilter, where the preview and both exporters +// already pick it up. It never lands here and never reaches the panel, so the +// EXPOSURE slider still reads 0 on such a sim — LC STREETLIFE VIVID (+2). +// +// Reference only — what each sim stands for, so the colour choices can be +// checked against the real stock. The UI keeps the sim names below. +// +// PROVIPES → PROVIA +// VELVIPES → VELVIA +// CLASSIC CHRIPES → CLASSIC CHROME +// CLASSIC VIVIDIPES → VELVIA + CLASSIC CHROME (Chrome's blue row over +// Velvia's red and green rows — see colorUtils.ts) +// CLASSIC NEGIPES → Classic Negative +// ASTIPES → Astia +// ETERNIPES → Eterna +// ACRIPES → Acros +// LC STREETLIFE CLASSIC → Leica Authentic Classic +// LC STREETLIFE VIVID → Leica Vivid export interface FilmSim { id: string; // Recipe-like id used only while the sim is the current selection name: string; @@ -51,20 +75,16 @@ export const FILM_SIMS: FilmSim[] = [ { id: 'sim-classic-neg', name: 'CLASSIC NEGIPES', baseFilter: 'classic-neg', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-astia', name: 'ASTIPES', baseFilter: 'astia', adjustments: { ...DEFAULT_ADJUSTMENTS } }, { id: 'sim-eterna', name: 'ETERNIPES', baseFilter: 'eterna', adjustments: { ...DEFAULT_ADJUSTMENTS } }, - { id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS, grain: 3 } }, + { id: 'sim-acros', name: 'ACRIPES', baseFilter: 'monochrome', adjustments: { ...DEFAULT_ADJUSTMENTS } }, // The rangefinder digital look (M/Q/SL) is not a film stock, so it ships as its own - // pair instead of one look that has to compromise: + // pair instead of one look that has to compromise. Both read off the + // 'leica'/'leica-vivid' colour matrices alone (neither has a FILM_TONE + // entry): // CLASSIC — the stock character: deep solid darks, natural shadows, rich but - // quiet colour. Its micro-contrast is CLARITY (a local unsharp mask in both - // renderers), never a global saturation boost, and a whisker of grain - // keeps it feeling like film rather than digital sharpness. - // VIVID — the same channel separation opened up for the digital era: lifted - // exposure, lifted shadows, a little colour. It reads bright and flattering - // straight off the shutter, no editing pass needed. - // Both share the 'leica'/'leica-vivid' matrices; the tonal half is the sim's - // own adjustments, which every apply path now carries into the panel. - { id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS, clarity: 2, grain: 1 } }, - { id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS, exposure: 2, saturation: 2, clarity: 2, shadow: 1, highlight: -1 } }, + // quiet colour. + // VIVID — the same channel separation opened up for the digital era. + { id: 'sim-leica', name: 'LC STREETLIFE CLASSIC', baseFilter: 'leica', adjustments: { ...DEFAULT_ADJUSTMENTS } }, + { id: 'sim-leica-vivid', name: 'LC STREETLIFE VIVID', baseFilter: 'leica-vivid', adjustments: { ...DEFAULT_ADJUSTMENTS } }, ]; // Film sim → full Recipe (never persisted; sims are applied on the fly).