web: add the B&W HIGH CONTRAST film sim to the PRESETS rail

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.
This commit is contained in:
2026-09-22 17:56:00 +07:00
parent 80512d11b5
commit c403dd04c8
5 changed files with 70 additions and 10 deletions
+3 -3
View File
@@ -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) {