Add the B&W HIGH CONTRAST film sim next to ACRIPES

A second black-and-white stock, so it gets its own baseFilter rather than
borrowing Acros': the PHOTO STYLE chips are keyed by baseFilter and the two
greys have to sit side by side.

The look is the B&W MIX plus a push at both ends. The mix rides the matrix — a
non-BT.709 row set (0.38/0.56/0.06, identical rows, sum 1.00) so a red roof
reads bright, a blue sky deep, and the separation is contrast before any curve.
The push rides FILM_TONE (shadow -0.32, highlight +0.26) so the ends move
without touching the midtones, and the midtone slope is SIM_CONTRAST_BIAS (4
contrast units). Every knob still opens at neutral: a sim is colour and tone
only.

Both B&W stocks being mono is now asked once, through isMonochromeBase, so the
colour-only stages (saturation, white balance, the R/B fine-tune) and the
chrome/hue-mixer gate can never half-apply to one of them.

Mirrors the web app's rail (c403dd0).
This commit is contained in:
2026-09-22 19:25:54 +07:00
parent c403dd04c8
commit 3d273a3188
4 changed files with 67 additions and 6 deletions
+5 -1
View File
@@ -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' | '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 that asks "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' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'mono-high-contrast' | 'leica' | 'leica-vivid' | 'none';
export interface ColorAdjustments {
exposure: number; // -10 to +10 (mapped to matrix multiplier or offset)
+47 -4
View File
@@ -52,6 +52,22 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
return { r: gain[0] * s, g: gain[1] * s, b: gain[2] * s };
}
// A brightness a stock owns, in Contrast-slider units. The knob itself stays
// at 0 (a sim is colour only), so the extra slope is added here, where the
// knob's own gain/offset mapping already lives.
const SIM_CONTRAST_BIAS: Partial<Record<BaseFilter, number>> = {
'mono-high-contrast': 4,
};
// Black-and-white stocks. Their grey ramp is the whole look, so every
// colour-only 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 —
@@ -60,7 +76,7 @@ function normalizeGainLuma(gain: number[]): { r: number; g: number; b: number }
// applyExposureGain (library), never doubled.
export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments): number[] {
const exposure = adj.exposure;
const contrast = adj.contrast;
const contrast = adj.contrast + (SIM_CONTRAST_BIAS[baseFilter] ?? 0);
const saturation = adj.saturation;
const temperature = adj.temperature;
const tint = adj.tint;
@@ -190,10 +206,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 the
// ends (the push itself lives in FILM_TONE, and its midtone slope in
// SIM_CONTRAST_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;
@@ -217,7 +260,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments
// consumed by getToneUniforms instead.
// 5. White Balance (Kelvin Temperature and Tint) — skip for monochrome to prevent color leakage
if (baseFilter !== 'monochrome') {
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
@@ -246,7 +289,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,
+5
View File
@@ -45,6 +45,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, grain: 3 } },
// 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 SIM_CONTRAST_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:
// CLASSIC — the stock character: deep solid darks, natural shadows, rich but
+10 -1
View File
@@ -1,4 +1,5 @@
import { BaseFilter, ColorAdjustments } from '../types';
import { isMonochromeBase } from './colorUtils';
// Tone-domain adjustments (Fuji-style Dynamic Range + Highlight/Shadow).
// SkSL runtime effect over a child image shader.
@@ -199,6 +200,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 → +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 strength the stock needs on the greys is its matrix slope
// (SIM_CONTRAST_BIAS in colorUtils), not another mask here.
'mono-high-contrast': { sh: -0.32, hl: 0.26 },
};
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {
@@ -213,7 +222,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) =>