From c403dd04c86dc2c9031c15fd4039262e8100fa9a Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 22 Sep 2026 17:56:00 +0700 Subject: [PATCH] web: add the B&W HIGH CONTRAST film sim to the PRESETS rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip lands after ACRIPES and is a mono stock of its own, so it gets its own baseFilter ('mono-high-contrast') rather than borrowing Acros': the PHOTO STYLE chips are keyed by baseFilter, and the two greys must 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) next to the sim's own exposure bias. The knobs stay 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, R/B fine-tune, chrome, hue mixer) and the MONO strip label can never half-apply to one of them. --- docker/frontend/shared/types/index.ts | 6 ++- docker/frontend/shared/utils/colorUtils.ts | 54 +++++++++++++++++-- .../frontend/shared/utils/defaultRecipes.ts | 2 + docker/frontend/shared/utils/toneShader.ts | 12 ++++- docker/frontend/src/App.tsx | 6 +-- 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index a0a6fe6..2668e67 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -68,7 +68,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 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' | 'classic-vivid' | 'provia' | 'velvia' | 'astia' | 'eterna' | 'monochrome' | 'mono-high-contrast' | 'leica' | 'leica-vivid' | 'none'; // The eight hue bands of the selective-colour mixer, in the order they wrap // around the wheel. HSL_BANDS (shared/utils/colorUtils.ts) carries the anchor diff --git a/docker/frontend/shared/utils/colorUtils.ts b/docker/frontend/shared/utils/colorUtils.ts index a709acb..cc3ad0e 100644 --- a/docker/frontend/shared/utils/colorUtils.ts +++ b/docker/frontend/shared/utils/colorUtils.ts @@ -147,6 +147,22 @@ const SIM_EXPOSURE_BIAS: Partial> = { 'leica-vivid': 2, }; +// A second 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> = { + '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, and the tone pass leaves the hue mixer alone. 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 — @@ -160,7 +176,7 @@ export function getSkiaColorMatrix(baseFilter: BaseFilter, adj: ColorAdjustments // mapping the knob uses, so the pixels get the lift the stock needs while the // UI keeps reading 0. const exposure = (adj.exposure ?? 0) + (SIM_EXPOSURE_BIAS[baseFilter] ?? 0); - 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; @@ -302,10 +318,40 @@ 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, orange and yellow hold or gain slightly + // (their rows carry the red weight plus a green one), while blue and green + // fall — 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 (the mixer block already + // has the tent weights to do it), 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; @@ -329,7 +375,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 @@ -358,7 +404,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, diff --git a/docker/frontend/shared/utils/defaultRecipes.ts b/docker/frontend/shared/utils/defaultRecipes.ts index a28b8d8..ab3d58f 100644 --- a/docker/frontend/shared/utils/defaultRecipes.ts +++ b/docker/frontend/shared/utils/defaultRecipes.ts @@ -52,6 +52,7 @@ export const DEFAULT_ADJUSTMENTS: ColorAdjustments = { // ASTIPES Astia / Soft // ETERNIPES Eterna (cinema) // ACRIPES Acros (monochrome) +// B&W HIGH CONTRAST Acros through a filter, ends pushed hard // LC STREETLIFE CLASSIC Leica Authentic Classic // LC STREETLIFE VIVID Leica Vivid export interface FilmSim { @@ -70,6 +71,7 @@ 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 } }, + { 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 are still colour and // tone only: CLASSIC is Leica Authentic (the matrix carries the channel diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index d617e81..14d5407 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -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. @@ -331,6 +331,14 @@ const FILM_TONE: Partial>> = { // 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 { @@ -347,7 +355,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) => diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 64d4088..1556cd6 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -33,7 +33,7 @@ import { type HslBandId, type Recipe, } from '../shared/types'; -import { HSL_BANDS, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils'; +import { HSL_BANDS, isMonochromeBase, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. @@ -1140,7 +1140,7 @@ export function Workspace() { const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase(); const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0)); const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250); - const tail = recipe.baseFilter === 'monochrome' ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`; + const tail = isMonochromeBase(recipe.baseFilter) ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`; return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` }; }, [recipe.name, recipe.adjustments, recipe.baseFilter, iso]); @@ -1960,7 +1960,7 @@ export function Workspace() { // FX's MONOCHROME switch is not a look of its own: it swaps the base filter // for the mono stock and swaps right back. Adjustments are never touched, so // a knob moved while the switch is on survives the trip back. - const monoOn = recipe.baseFilter === 'monochrome'; + const monoOn = isMonochromeBase(recipe.baseFilter); const toggleMono = () => { remember(); if (monoOn) {