android: keep film sims colour-only, and fix the Vivid exposure in config

A sim is a stock, not an edit: its whole look must come out of the baseFilter
colour matrix and the FILM_TONE curve, so every entry in FILM_SIMS now carries
the neutral DEFAULT_ADJUSTMENTS verbatim. Dropped the effects that had been
riding along — grain on ACRIPES, clarity+grain on LC STREETLIFE CLASSIC, and
exposure/saturation/clarity/shadow/highlight on LC STREETLIFE VIVID.

The Vivid brightness is real, though, so it moves into STOCK_EXPOSURE in
colorUtils, keyed by baseFilter: a fixed exposure move of the look (2), the
same maths the EXPOSURE slider runs (1.04x + 0.03). Keyed by stock because that
is what the preview and both exporters know — baseFilter travels with every
recipe, an armed sim id does not. A sim's own adjustments stay neutral, so the
EXPOSURE slider reads 0 on it.

Also records the naming rule in the FILM_SIMS header: COLOUR AND TONE ONLY, and
the real stock each sim stands for (reference only, the UI keeps the sim names).
This commit is contained in:
2026-09-22 08:29:37 +07:00
parent cbd0178cda
commit c794283083
2 changed files with 58 additions and 23 deletions
+19 -4
View File
@@ -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<Record<BaseFilter, number>> = {
'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,
+39 -19
View File
@@ -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).