Files
RecipesCam/docker/frontend/src/ui/RecipeCreatePanel.tsx
T
3dtours 0e9f78bd5e web: give the grain a size of its own, and read the count off the print
MONOCHROME GRAIN was one integer knob 0..10 with one meaning, how much. It is now
a strip of three: AMOUNT — the same knob, in half steps — SIZE, a percentage of
the stock's own grain cell (50..200%, so the same number means the same texture
relative to the picture on both platforms), and an inert readout of N/INCH, the
clump count the two knobs and the stock add up to in the print's own terms (300
dpi = 300px of the 1080-wide reference the knob was tuned at).

Emulsion is not one grain size across the frame: the coating settles unevenly.
The field now prints that — the same hash read slowly (ZONE_FREQ = 1/96 cells,
turned off the axes, smoothed so a border between two patches is a slope and not
a seam) swings each patch's own cell by half of ZONE_SWING either way, ±20%.
Nothing in it moves the field's mean: a coarser patch prints bigger clumps, not a
brighter one, which is why the strip can read out one number while the frame
carries a range.

A patch may not swing a cell under the pixel the target can print, or the clumps
are sub-pixel and print as static — aliasing, not a finer emulsion. The shader
takes that floor as a `mincell` uniform beside the cell (u, mincell, seed.xy, in
declaration order): an export passes one output pixel, a preview one device pixel
(1 / PixelRatio), which is the floor the phone's preview already needed.

SIZE is stored as an integer percent so no float noise reaches the recipe JSON,
and it is read by the same two engines that read grain: the web's
grainCell(width, stock, sizePct) and the phone's grainCell(width, minCell,
sizePct). The chip above the strip carries the amount in half steps the way TEMP
carries the kelvin, and the readout moves with SIZE, not with AMOUNT.

Measured:
  grain-controls-test.cjs 20/0 — the strip carries grp-grain, grain:amount,
    grain:size and grain-inch; the AMOUNT ruler is 0..10 step 0.5, and 3 -> 3.5
    moves the frame (sigma 18.53 -> 21.91, new hash) without moving the readout;
    10 -> sigma 60.43, 0 -> sigma 0; SIZE 200% -> 130/INCH (sigma 40.06), 50% ->
    522/INCH (66.56: under the preview's pixel floor what is printed is static,
    not finer grain); the region claim on an 8x8 grid of the flat frame gives
    tile sd 51.0..66.1, max/min 1.295, and a tile mean spread of 1.14 — a coarser
    patch is not a brighter one.
  grain-size-test.cjs 17/0 (was 12/0) — the SIZE rule and the readout on the
    module itself: 200% doubles the cell, 50% halves it, the output pixel still
    floors the smaller one. 4000px file cell 3.704 against 1.083 device px on a 3x
    preview, the old one-dp floor 2.77x coarser, rho1 0.627 against 0.074. The
    harness built its own 3-uniform array; it now passes [u, mincell, seed.xy]
    like every other caller.
  _grain-zone-ck.cjs — the zone's own contribution, at preview scale (cell 1.70,
    1600px, 8x8 tiles of 200px): zone on, tile sd 23.22..24.82 (ratio 1.069);
    zone off, 24.42..24.79 (ratio 1.015). Nothing else differs.
  _grain-ck.cjs — the clump field is otherwise what it was: rho1 0.62
    classic-neg / 0.12 velvia, residual autocorr 0.035 against 0.036 with the
    swing forced to 0, peak/median 32.3 against 27.6.
  _grain-spectrum.cjs (app, 1600px render) — residual autocorr 0.017..0.018,
    spectral peak/median 4.6..6.1: the slow lattice adds no peak of its own.
  grain-stock-test 53/0, sims-test 31/0, fx-mono-test 15/0, wb-preset-test 33/0,
    temp-swatch-test 33/0, wm-font-test 38/0, grain-analog-test 7/0 (its grain
    selectors moved to the strip).
  tsc: web clean; the phone's scoped config reports exactly the pre-change
    baseline (Viewfinder.tsx's own errors, none new).

ponytail: the amount is fractional now, so the two recipe-create forms read grain
through their own half() instead of the int() that would truncate the half the
ruler just spent — every other knob there is still whole. The SIZE knob is one
number for the whole strip: no way to dial a single patch, and no seed control.
The readout is the DESIGN count the field is built on, never a per-patch
measurement.
2026-09-23 15:26:23 +07:00

373 lines
14 KiB
TypeScript

import { useEffect, useRef, useState, type ReactNode } from 'react';
import { ChipRow, type ChipDef } from './ChipColumn';
import { useI18n } from '../i18n/I18nProvider';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../../shared/utils/defaultRecipes';
import type { BaseFilter, ColorAdjustments, Recipe } from '../../shared/types';
// The phone's RecipeCreateModal, minus the modal: the same rows in the same
// order with the same clamping. CREATE opens on the look live on screen (the
// phone's rule — a recipe is a snapshot of what the user is looking at).
export type RecipeDraft = Omit<Recipe, 'id' | 'isCustom'>;
// WB presets mirror the phone's WB-tab chips but keep pure presets (no -R
// variants — those are the separate Red/Blue offset boxes below). SHADE is the
// WB tab's own 7500K/+2: it read 8000K here, which made a recipe created in one
// place print a different white balance from the same chip picked in the other.
const WB_PRESETS: { key: string; label: string; temperature: number; tint: number }[] = [
{ key: 'auto', label: 'AUTO', temperature: 5500, tint: 0 },
{ key: 'daylight', label: 'DAYLIGHT', temperature: 5500, tint: 0 },
{ key: 'cloudy', label: 'CLOUDY', temperature: 6500, tint: 1 },
{ key: 'shade', label: 'SHADE', temperature: 7500, tint: 2 },
{ key: 'tungsten', label: 'TUNGSTEN', temperature: 3200, tint: 0 },
{ key: 'fluor', label: 'FLUOR', temperature: 4000, tint: 3 },
];
const WB_KEYS = WB_PRESETS.map((w) => w.key);
const DR_OPTS = [
{ v: 'auto', d: 'AUTO' },
{ v: '100', d: 'DR100' },
{ v: '200', d: 'DR200' },
{ v: '400', d: 'DR400' },
];
const CC_OPTS = [
{ v: 'none', d: 'OFF' },
{ v: 'weak', d: 'WEAK' },
{ v: 'strong', d: 'STRONG' },
];
const GRAIN_OPTS = [
{ v: '0', d: 'OFF' },
{ v: '3', d: 'WEAK' },
{ v: '6', d: 'STRONG' },
];
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const int = (s: string, fallback: number, lo: number, hi: number) => {
const n = parseInt(s, 10);
return Number.isFinite(n) ? clamp(n, lo, hi) : fallback;
};
// GRAIN's own row: the ruler spends HALF steps (paramDefs.ts), so the form must
// not drop the half the strip was showing — the chips are its coarse picks, and
// the live value rides along on a chip of its own. Only grain is read this way;
// every other knob here is a whole number.
const half = (s: string, fallback: number, lo: number, hi: number) => {
const n = Number(s);
return Number.isFinite(n) ? clamp(Math.round(n * 2) / 2, lo, hi) : fallback;
};
const wbKeyOf = (s: ColorAdjustments) =>
WB_KEYS.find((k) => {
const p = WB_PRESETS.find((w) => w.key === k)!;
return p.temperature === s.temperature && p.tint === s.tint;
}) ?? 'custom';
// One collapsible group. A native <details> — no state, no library, and the
// browser keeps the open/closed flag itself (the `open` prop only seeds it).
function Fold({ label, children }: { label: string; children: ReactNode }) {
return (
<details className="create-sec" open>
<summary className="create-label">{label}</summary>
{children}
</details>
);
}
export function RecipeCreatePanel({
currentBase,
current,
signedIn,
onPreview,
onSave,
onExport,
resetToken = 0,
}: {
currentBase?: BaseFilter;
current?: ColorAdjustments | null;
signedIn: boolean;
// Bumped by the RESET chip above the form: the draft drops back to the state
// the form opened with.
resetToken?: number;
onPreview: (draft: Pick<Recipe, 'baseFilter' | 'adjustments'>) => void;
onSave: (draft: RecipeDraft) => void;
onExport: (draft: RecipeDraft) => void;
}) {
const { t } = useI18n();
const seedBase: BaseFilter =
currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia';
const seedAdj: ColorAdjustments =
current ?? FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ?? DEFAULT_ADJUSTMENTS;
// Every sim's standard values as form state — used to seed the form when the
// SIMULATION chip changes.
const valuesOf = (b: BaseFilter, src?: ColorAdjustments) => {
const s = src ?? FILM_SIMS.find((x) => x.baseFilter === b)?.adjustments ?? DEFAULT_ADJUSTMENTS;
return {
dr: String(s.dynamicRange ?? 'auto'),
grain: String(s.grain ?? 0),
cc: s.colorChrome ?? 'none',
ccb: s.colorChromeBlue ?? 'none',
wbKey: wbKeyOf(s),
highlight: String(s.highlight),
shadow: String(s.shadow),
whites: String(s.whites ?? 0),
blacks: String(s.blacks ?? 0),
exposure: String(s.exposure),
color: String(s.saturation),
nr: String(s.denoise),
sharp: String(s.sharpening),
clarity: String(s.clarity),
ev: String(s.exposureCompensation),
};
};
const [sim, setSim] = useState<BaseFilter>(seedBase);
const [name, setName] = useState('');
const [dr, setDr] = useState<string>(seedAdj.dynamicRange == null ? 'auto' : String(seedAdj.dynamicRange));
const [grain, setGrain] = useState<string>(String(seedAdj.grain ?? 0));
const [cc, setCc] = useState<string>(seedAdj.colorChrome ?? 'none');
const [ccb, setCcb] = useState<string>(seedAdj.colorChromeBlue ?? 'none');
const [wbKey, setWbKey] = useState<string>(wbKeyOf(seedAdj));
const [wbRed, setWbRed] = useState(String(seedAdj.wbRed ?? 0));
const [wbBlue, setWbBlue] = useState(String(seedAdj.wbBlue ?? 0));
const [fHigh, setFHigh] = useState(String(seedAdj.highlight));
const [fShadow, setFShadow] = useState(String(seedAdj.shadow));
const [fWhites, setFWhites] = useState(String(seedAdj.whites ?? 0));
const [fBlacks, setFBlacks] = useState(String(seedAdj.blacks ?? 0));
const [fExposure, setFExposure] = useState(String(seedAdj.exposure));
const [fColor, setFColor] = useState(String(seedAdj.saturation));
const [fNr, setFNr] = useState(String(seedAdj.denoise));
const [fSharp, setFSharp] = useState(String(seedAdj.sharpening));
const [fClarity, setFClarity] = useState(String(seedAdj.clarity));
const [fEv, setFEv] = useState(String(seedAdj.exposureCompensation));
// Switching the simulation reloads its standard values into the form.
const selectSim = (b: BaseFilter) => {
setSim(b);
const v = valuesOf(b);
setDr(v.dr);
setGrain(v.grain);
setCc(v.cc);
setCcb(v.ccb);
setWbKey(v.wbKey);
setFHigh(v.highlight);
setFShadow(v.shadow);
setFWhites(v.whites);
setFBlacks(v.blacks);
setFExposure(v.exposure);
setFColor(v.color);
setFNr(v.nr);
setFSharp(v.sharp);
setFClarity(v.clarity);
setFEv(v.ev);
};
// Same assembly as the phone's handleSave: the live look rides along for every
// knob the form has no row for, so the saved recipe matches the screen.
const build = (): RecipeDraft => {
const wb = WB_PRESETS.find((w) => w.key === wbKey);
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
const adjustments: ColorAdjustments = {
...DEFAULT_ADJUSTMENTS,
...seedAdj,
temperature: wb ? wb.temperature : seedAdj.temperature,
tint: wb ? wb.tint : seedAdj.tint,
wbRed: int(wbRed, 0, -9, 9),
wbBlue: int(wbBlue, 0, -9, 9),
highlight: int(fHigh, simAdj.highlight, -10, 10),
shadow: int(fShadow, simAdj.shadow, -10, 10),
whites: int(fWhites, simAdj.whites ?? 0, -10, 10),
blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10),
exposure: int(fExposure, simAdj.exposure, -10, 10),
saturation: int(fColor, simAdj.saturation, -10, 10),
denoise: int(fNr, simAdj.denoise, -10, 10),
sharpening: int(fSharp, simAdj.sharpening, -10, 10),
clarity: int(fClarity, simAdj.clarity, -10, 10),
grain: half(grain, seedAdj.grain, 0, 10),
colorChrome: cc as ColorAdjustments['colorChrome'],
colorChromeBlue: ccb as ColorAdjustments['colorChromeBlue'],
dynamicRange: (dr === 'auto' ? 'auto' : parseInt(dr, 10)) as ColorAdjustments['dynamicRange'],
exposureCompensation: int(fEv, simAdj.exposureCompensation, -3, 3),
};
return { name: name.trim() || 'NEW RECIPE', baseFilter: sim, adjustments, frameId: 'none', useGeotag: true };
};
// RESET clears the draft: back to the seed simulation, its standard values and
// an empty name. The form previews live, so the photo follows it home.
useEffect(() => {
if (resetToken === 0) return;
selectSim(seedBase);
setName('');
setWbRed(String(seedAdj.wbRed ?? 0));
setWbBlue(String(seedAdj.wbBlue ?? 0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetToken]);
// Realtime: every knob applies to the photo on screen as it is typed, so the
// effect of the recipe is visible before saving it. Opening the tab is not an
// edit — the first run (the seeded values) is skipped.
const first = useRef(true);
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const { baseFilter, adjustments } = build();
onPreview({ baseFilter, adjustments });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sim, dr, grain, cc, ccb, wbKey, wbRed, wbBlue, fHigh, fShadow, fWhites, fBlacks, fColor, fNr, fSharp, fClarity, fExposure, fEv]);
const opts = (
prefix: string,
options: { v: string; d: string }[],
value: string,
onPick: (v: string) => void
): ChipDef[] =>
options.map((o) => ({
key: `${prefix}:${o.v}`,
label: o.d,
active: value === o.v,
onClick: () => onPick(o.v),
}));
const num = (
label: string,
value: string,
set: (s: string) => void,
ph?: string
) => (
<label className="create-num" key={label}>
<span>{label}</span>
<input
className="field"
data-key={`create-${label}`}
value={value}
placeholder={ph ?? '0'}
inputMode="numeric"
onChange={(e) => set(e.target.value)}
/>
</label>
);
return (
<div className="create-form" data-key="create-form">
<h2>{t('create.title')}</h2>
<label className="create-num wide">
<span>NAME</span>
<input
className="field"
data-key="create-name"
value={name}
placeholder="Recipe name (e.g. Retro Film)"
maxLength={40}
onChange={(e) => setName(e.target.value)}
/>
</label>
<Fold label="SIMULATION">
<ChipRow
chips={FILM_SIMS.map((s) => ({
key: s.id,
label: s.name,
active: sim === s.baseFilter,
onClick: () => selectSim(s.baseFilter),
}))}
/>
</Fold>
<Fold label="DYNAMIC RANGE">
<ChipRow chips={opts('dr', DR_OPTS, dr, setDr)} />
</Fold>
<div className="create-grid">
{[
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'WHITE', value: fWhites, set: setFWhites },
{ label: 'BLACK', value: fBlacks, set: setFBlacks },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE', value: fExposure, set: setFExposure },
{ label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' },
].map((f) => num(f.label, f.value, f.set, f.ph))}
</div>
<Fold label="GRAIN EFFECT">
<ChipRow chips={opts('grain', GRAIN_OPTS, grain, setGrain)} />
</Fold>
<Fold label="COLOR CHROME EFFECT">
<ChipRow chips={opts('cc', CC_OPTS, cc, setCc)} />
</Fold>
<Fold label="COLOR CHROME EFFECT BLUE">
<ChipRow chips={opts('ccb', CC_OPTS, ccb, setCcb)} />
</Fold>
<Fold label="WHITE BALANCE">
<ChipRow
chips={[
...opts('wb', WB_PRESETS.map((w) => ({ v: w.key, d: w.label })), wbKey, setWbKey),
...(wbKey === 'custom'
? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }]
: []),
]}
/>
<div className="create-shift">
<span className="create-label">WB SHIFT</span>
<span className="create-label">RED</span>
<input
className="field"
data-key="create-wbRed"
value={wbRed}
placeholder="-9..9"
inputMode="numeric"
onChange={(e) => setWbRed(e.target.value)}
/>
<span className="create-label">BLUE</span>
<input
className="field"
data-key="create-wbBlue"
value={wbBlue}
placeholder="-9..9"
inputMode="numeric"
onChange={(e) => setWbBlue(e.target.value)}
/>
</div>
</Fold>
<div className="create-shift">
<span className="create-label">ISO</span>
<ChipRow chips={[{ key: 'iso-auto', label: 'AUTO (camera default)', disabled: true, onClick: () => {} }]} />
</div>
<div className="create-actions">
<button
type="button"
className="btn primary"
data-key="create-save"
onClick={() => onSave(build())}
>
{t('create.save')}
</button>
<button
type="button"
className="btn"
data-key="create-export"
disabled={!signedIn}
title={signedIn ? t('create.export') : t('create.guestHint')}
onClick={() => onExport(build())}
>
{t('create.export')}
</button>
</div>
<p className="hint">{signedIn ? t('create.userHint') : t('create.guestHint')}</p>
</div>
);
}