android: add the B&W HIGH CONTRAST film sim next to ACRIPES
Ported from the web studio's 3d273a3, and folded into this branch's
colour-only rule for sims.
- `mono-high-contrast` joins BaseFilter, with its own matrix (a warm B&W
mix: red 0.38 / green 0.56 / blue 0.06, rows equal and summing to 1.00)
and its own FILM_TONE entry (toe -0.32, shoulder +0.26 — the whites step
of the brief). The sim ships with neutral adjustments like every other.
- The stock's own slope (+4 of Contrast) rides `STOCK_BIAS`, which absorbs
the former `STOCK_EXPOSURE` table: one place for tone a stock carries by
itself, keyed by baseFilter, so the EXPOSURE/CONTRAST sliders keep
reading 0 while the render is already pushed.
- Two B&W stocks now exist, so every "is this a colour stock?" test goes
through the new `isMonochromeBase` instead of comparing against
'monochrome' — a stray Kelvin gain on a grey ramp shows up as a tint.
This commit is contained in:
+5
-1
@@ -62,7 +62,11 @@ export const MIN_CROP_FRAC = 0.08;
|
||||
// cool tint in shade, plus a seasonal atmospheric haze/fog veil.
|
||||
export type CinemaSeason = 'spring' | 'summer' | 'autumn' | 'winter';
|
||||
|
||||
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'leica' | 'leica-vivid' | 'none';
|
||||
// `monochrome` is Acros' grey ramp; `mono-high-contrast` is the B&W HIGH
|
||||
// CONTRAST stock's own — both are black-and-white, so anything asking "is this
|
||||
// a colour stock?" must ask it through isMonochromeBase (colorUtils.ts)
|
||||
// rather than comparing against one of these two strings.
|
||||
export type BaseFilter = 'classic-neg' | 'classic-chrome' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'mono-high-contrast' | 'leica' | 'leica-vivid' | 'none';
|
||||
|
||||
// Selective colour mixer: the eight hue bands the HSL tab edits, and the
|
||||
// [hue, sat, lum] triple each band carries (-10..+10 per knob). A band left at
|
||||
|
||||
+58
-18
@@ -150,18 +150,30 @@ 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
|
||||
// Tone a stock carries BY ITSELF, in the sliders' own units: brightness
|
||||
// (EXPOSURE) and slope (CONTRAST). 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 or the ramp already steeper. Keyed by stock because that is
|
||||
// what the renderers know: `baseFilter` travels with every recipe, an armed sim
|
||||
// id does not. ponytail: one entry per stock; if two sims ever need one stock's
|
||||
// matrix with a different tone, the key has to move to the sim id.
|
||||
const STOCK_BIAS: Partial<Record<BaseFilter, { exposure?: number; contrast?: number }>> = {
|
||||
// LC STREETLIFE VIVID reads bright straight off the shutter.
|
||||
'leica-vivid': { exposure: 2 },
|
||||
// B&W HIGH CONTRAST pushes its own greys (+4 of slope), on top of the
|
||||
// toe/shoulder FILM_TONE gives it.
|
||||
'mono-high-contrast': { contrast: 4 },
|
||||
};
|
||||
|
||||
// Black-and-white stocks. Their grey ramp is the whole look, so every colour
|
||||
// stage below — saturation, white balance, the R/B fine-tune — is skipped
|
||||
// for them. One list, asked through one function, so a second B&W stock cannot
|
||||
// end up half excluded (a stray Kelvin gain on a grey ramp shows up as a tint).
|
||||
const MONO_BASES: BaseFilter[] = ['monochrome', 'mono-high-contrast'];
|
||||
export const isMonochromeBase = (baseFilter: BaseFilter | undefined): boolean =>
|
||||
!!baseFilter && MONO_BASES.includes(baseFilter);
|
||||
|
||||
// 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 —
|
||||
@@ -169,10 +181,10 @@ const STOCK_EXPOSURE: Partial<Record<BaseFilter, 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[] {
|
||||
// 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;
|
||||
// The stock's own tone rides with the sliders: same maths, same band (it is
|
||||
// a property of the look, not of this photo's edit).
|
||||
const exposure = adj.exposure + (STOCK_BIAS[baseFilter]?.exposure ?? 0);
|
||||
const contrast = adj.contrast + (STOCK_BIAS[baseFilter]?.contrast ?? 0);
|
||||
const saturation = adj.saturation;
|
||||
const temperature = adj.temperature;
|
||||
const tint = adj.tint;
|
||||
@@ -313,10 +325,37 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
r, g, b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
} else if (baseFilter === 'mono-high-contrast') {
|
||||
// B&W HIGH CONTRAST — Acros taken through a warm filter, then pushed at
|
||||
// both ends (the push itself lives in FILM_TONE, its midtone slope in
|
||||
// STOCK_BIAS above).
|
||||
//
|
||||
// The mix is the B&W MIX step: the channel weights are the matrix's whole
|
||||
// job here, and they are deliberately not BT.709. Red is nearly doubled
|
||||
// against its luminance share — so a red roof reads bright, a blue sky
|
||||
// and foliage read deep, and the separation between them is what gives the
|
||||
// ramp its contrast before any curve is applied.
|
||||
//
|
||||
// ponytail: the weights ride the CHANNELS, while the brief that asked for
|
||||
// this stock names HUE groups (red 1.15, orange 1.20, blue 0.70). A 4x5
|
||||
// matrix cannot be hue-selective; if those exact factors are ever wanted,
|
||||
// that is a per-pixel HSL pass in the tone shader, not a row edit here.
|
||||
//
|
||||
// The three rows are identical and sum to 1.00, so a neutral stays the
|
||||
// neutral the sensor recorded and nothing clips at either end.
|
||||
const r = 0.38;
|
||||
const g = 0.56;
|
||||
const b = 0.06;
|
||||
matrix = [
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
r, g, b, 0, 0,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
}
|
||||
|
||||
// 3. Apply Saturation adjustment (standard color matrix transformation)
|
||||
if (baseFilter !== 'monochrome' && saturation !== 0) {
|
||||
if (!isMonochromeBase(baseFilter) && saturation !== 0) {
|
||||
const s = 1 + (saturation / 10) * 0.5; // -10 maps to 0.5x, +10 maps to 1.5x saturation
|
||||
const invS = 1 - s;
|
||||
const r = 0.213 * invS;
|
||||
@@ -339,8 +378,9 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
// blue-only gain tints the white point. `colorChrome`/`colorChromeBlue` are
|
||||
// consumed by getToneUniforms instead.
|
||||
|
||||
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
|
||||
if (baseFilter !== 'monochrome') {
|
||||
// 5. White Balance (Kelvin Temperature and Tint) — skipped for the B&W
|
||||
// stocks: a grey ramp has no cast to correct, only a tint to leak.
|
||||
if (!isMonochromeBase(baseFilter)) {
|
||||
const rgbTemp = kelvinToRGB(temperature);
|
||||
// Tint is the green↔magenta axis, and it is symmetric: + pushes magenta
|
||||
// (red and blue up, green down), - pushes green (green up, red and blue
|
||||
@@ -369,7 +409,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
|
||||
// perceptible size of one Fuji fine-tune step (-9..+9).
|
||||
const wbRed = adj.wbRed ?? 0;
|
||||
const wbBlue = adj.wbBlue ?? 0;
|
||||
if (baseFilter !== 'monochrome' && (wbRed !== 0 || wbBlue !== 0)) {
|
||||
if (!isMonochromeBase(baseFilter) && (wbRed !== 0 || wbBlue !== 0)) {
|
||||
const shiftMat = [
|
||||
1 + (wbRed / 9) * 0.1, 0, 0, 0, 0,
|
||||
0, 1, 0, 0, 0,
|
||||
|
||||
@@ -76,6 +76,11 @@ export const FILM_SIMS: FilmSim[] = [
|
||||
{ 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 } },
|
||||
// Acros taken through a warm filter, then pushed at both ends: the mix (red
|
||||
// up, blue down) rides the matrix, the end push rides FILM_TONE, and the
|
||||
// midtone slope the stock owns is STOCK_BIAS in colorUtils — never an
|
||||
// `adjustments` value, so every knob still opens at neutral.
|
||||
{ id: 'sim-bw-high-contrast', name: 'B&W HIGH CONTRAST', baseFilter: 'mono-high-contrast', 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. Both read off the
|
||||
// 'leica'/'leica-vivid' colour matrices alone (neither has a FILM_TONE
|
||||
|
||||
+10
-2
@@ -1,5 +1,5 @@
|
||||
import { BaseFilter, ColorAdjustments } from '../types';
|
||||
import { HSL_BANDS, hslBandGaps } from './colorUtils';
|
||||
import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
|
||||
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
|
||||
// SkSL runtime effect over a child image shader.
|
||||
@@ -312,6 +312,14 @@ const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||||
// Gains are TONE_SKSL's own (sh * 0.34, hl * 0.22), so -0.12 puts the toe at
|
||||
// ~5% and -0.05 trims the top ~1%.
|
||||
monochrome: { sh: -0.12, hl: -0.05 },
|
||||
// B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe
|
||||
// (-0.32 against Acros' -0.12) so the darks reach true black, and a shoulder
|
||||
// that LIFTS instead of rolling (-0.05 becomes +0.26), which is the whites
|
||||
// step of the brief. Midtones see neither mask, so the long smooth stretch
|
||||
// between the two ends survives — that is what keeps a hard push off the
|
||||
// posterised look, and the slope this stock needs on the greys is its matrix
|
||||
// gain (STOCK_BIAS in colorUtils), not another mask here.
|
||||
'mono-high-contrast': { sh: -0.32, hl: 0.26 },
|
||||
};
|
||||
|
||||
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {
|
||||
@@ -326,7 +334,7 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
// Color Chrome depth per stop of the UI's none/weak/strong. A chrome set is a
|
||||
// monochrome look, so both are forced off there: the effect is colour-only
|
||||
// (the preview/export matrix skips them for monochrome for the same reason).
|
||||
const colour = baseFilter !== 'monochrome';
|
||||
const colour = !isMonochromeBase(baseFilter);
|
||||
const chromeDepth = (v: ColorAdjustments['colorChrome'] | undefined) =>
|
||||
!colour || v === 'none' || v == null ? 0 : v === 'strong' ? 0.9 : 0.45;
|
||||
const blueDepth = (v: ColorAdjustments['colorChromeBlue'] | undefined) =>
|
||||
|
||||
Reference in New Issue
Block a user