0e9f78bd5e
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.
479 lines
20 KiB
TypeScript
479 lines
20 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
Modal,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { Recipe, ColorAdjustments, BaseFilter } from '../types';
|
|
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../utils/defaultRecipes';
|
|
|
|
// "CREATE" quick-input form shown above the viewfinder. Numeric knobs are
|
|
// plain textboxes (typed fast, one row each), categorical knobs are chip
|
|
// dropdowns. Used to enter Fuji-style recipes (DR/Highlight/Shadow/Color/NR/
|
|
// Sharpness/Clarity/Grain/CC/CCB/WB+shift/ISO/EV) without touching sliders.
|
|
|
|
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
|
|
interface RecipeCreateModalProps {
|
|
visible: boolean;
|
|
// Id of the currently applied film sim (or bundled recipe whose look should
|
|
// prefill the SIMULATION row); undefined → default to PROVIPES.
|
|
currentBase?: BaseFilter;
|
|
// The look live on screen. CREATE opens on THESE values (not on the sim's
|
|
// standard ones) so a recipe is a snapshot of what the user is looking at;
|
|
// every knob the form has no row for rides along on SAVE.
|
|
current?: ColorAdjustments | null;
|
|
// When set the form opens as an EDITOR: every row (name included) is
|
|
// prefilled from this recipe instead of the sim's standard values, and
|
|
// SAVE hands the edited copy back for the caller to store.
|
|
initial?: Recipe | null;
|
|
onClose: () => void;
|
|
onSave: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
|
|
}
|
|
|
|
// WB presets mirror the WB-tab chips but keep pure presets (no -R variants —
|
|
// those are the separate Red/Blue offset textboxes below). SHADE is the WB tab's
|
|
// own 7500K/+2: it read 8000K here, which made a recipe created in the modal
|
|
// print a different white balance from the same chip picked on the photo.
|
|
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' },
|
|
] as const;
|
|
const CC_OPTS = [
|
|
{ v: 'none', d: 'OFF' },
|
|
{ v: 'weak', d: 'WEAK' },
|
|
{ v: 'strong', d: 'STRONG' },
|
|
] as const;
|
|
const GRAIN_OPTS = [
|
|
{ v: '0', d: 'OFF' },
|
|
{ v: '3', d: 'WEAK' },
|
|
{ v: '6', d: 'STRONG' },
|
|
] as const;
|
|
|
|
// ISO has no manual control path on this build (the vision-camera lib only
|
|
// exposes exposure locking on iOS; Android CameraX keeps ISO automatic), so the
|
|
// modal shows AUTO. If a native path ever appears, feed isoSupported=true and
|
|
// the row below turns into a selectable chip list.
|
|
const ISO_SUPPORTED = false;
|
|
const ISO_STOPS = [200, 400, 800, 1600, 3200, 6400];
|
|
|
|
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;
|
|
};
|
|
|
|
interface ChipProps {
|
|
label: string;
|
|
active: boolean;
|
|
onPress: () => void;
|
|
disabled?: boolean;
|
|
}
|
|
const Chip = ({ label, active, onPress, disabled }: ChipProps) => (
|
|
<TouchableOpacity
|
|
onPress={() => { if (!disabled) { haptic(); onPress(); } }}
|
|
className={`mx-0.5 rounded-full border px-2.5 py-1 ${
|
|
disabled
|
|
? 'border-zinc-800 bg-black/30'
|
|
: active
|
|
? 'border-amber-500/70 bg-amber-500/15'
|
|
: 'border-zinc-700/60 bg-titan/80'
|
|
}`}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text
|
|
className={`font-mono text-[10px] font-bold tracking-wide ${
|
|
disabled ? 'text-zinc-600' : active ? 'text-amber-500' : 'text-zinc-300'
|
|
}`}
|
|
>
|
|
{label}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
|
|
const ChipRow = ({ children }: { children: React.ReactNode }) => (
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={{ alignItems: 'center', paddingVertical: 2 }}
|
|
>
|
|
{children}
|
|
</ScrollView>
|
|
);
|
|
|
|
const LABEL_W = 132;
|
|
|
|
interface NumRowProps {
|
|
label: string;
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
placeholder?: string;
|
|
width?: number;
|
|
}
|
|
const NumRow = ({ label, value, onChange, placeholder, width = 58 }: NumRowProps) => (
|
|
<View style={{ width: '48%' }} className="mb-1.5">
|
|
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">
|
|
{label}
|
|
</Text>
|
|
<TextInput
|
|
value={value}
|
|
onChangeText={onChange}
|
|
selectTextOnFocus
|
|
keyboardType="numbers-and-punctuation"
|
|
placeholder={placeholder ?? '0'}
|
|
placeholderTextColor="#52525b"
|
|
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
|
|
style={{ width }}
|
|
/>
|
|
</View>
|
|
);
|
|
|
|
export default function RecipeCreateModal({
|
|
visible,
|
|
currentBase,
|
|
current,
|
|
initial,
|
|
onClose,
|
|
onSave,
|
|
}: RecipeCreateModalProps) {
|
|
const insets = useSafeAreaInsets();
|
|
// Seed from what the user is LOOKING AT: the recipe being edited, else the live
|
|
// adjustments, else the current sim's standard values. Seeding from the sim's
|
|
// standard values alone (the old behaviour) meant the form showed — and SAVE
|
|
// wrote — neutral numbers for knobs the user had already dialled in.
|
|
const seedBase: BaseFilter =
|
|
initial?.baseFilter ??
|
|
(currentBase && FILM_SIMS.some((s) => s.baseFilter === currentBase) ? currentBase : 'provia');
|
|
const seedAdj: ColorAdjustments =
|
|
initial?.adjustments ??
|
|
current ??
|
|
FILM_SIMS.find((s) => s.baseFilter === seedBase)?.adjustments ??
|
|
DEFAULT_ADJUSTMENTS;
|
|
|
|
// Every sim's standard values as form state — used to seed the modal and to
|
|
// reload 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'),
|
|
// Raw, not rounded to the three chips: a look sitting on grain 2 must not
|
|
// become 3 just because the form was opened (and the chip row below shows
|
|
// that value as its own chip when it is none of OFF/WEAK/STRONG).
|
|
grain: String(s.grain ?? 0),
|
|
cc: s.colorChrome ?? 'none',
|
|
ccb: s.colorChromeBlue ?? 'none',
|
|
// 'custom' = a WB the six chips do not name (a hand-set 6300K, say). It
|
|
// keeps its own chip and its own value; the old AUTO fallback rewrote the
|
|
// recipe's WB to 5500K on every save.
|
|
wbKey: WB_KEYS.find((k) => {
|
|
const p = WB_PRESETS.find((w) => w.key === k)!;
|
|
return p.temperature === s.temperature && p.tint === s.tint;
|
|
}) ?? 'custom',
|
|
highlight: String(s.highlight),
|
|
shadow: String(s.shadow),
|
|
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>(
|
|
WB_KEYS.find((k) => {
|
|
const p = WB_PRESETS.find((w) => w.key === k)!;
|
|
return p.temperature === seedAdj.temperature && p.tint === seedAdj.tint;
|
|
}) ?? 'custom'
|
|
);
|
|
const [wbRed, setWbRed] = useState('0');
|
|
const [wbBlue, setWbBlue] = useState('0');
|
|
const [fHigh, setFHigh] = useState(String(seedAdj.highlight));
|
|
const [fShadow, setFShadow] = useState(String(seedAdj.shadow));
|
|
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));
|
|
const [iso, setIso] = useState<number | null>(null);
|
|
|
|
// Re-seed whenever the modal opens (or the applied look changes) so CREATE
|
|
// quick-input always starts from the current simulation.
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
// Editing a stored recipe wins over the live sim seed: the form must show
|
|
// that recipe's own values, not the sim's standard ones.
|
|
const base = initial?.baseFilter ?? seedBase;
|
|
const v = valuesOf(base, seedAdj);
|
|
setSim(base);
|
|
setDr(v.dr);
|
|
setGrain(v.grain);
|
|
setCc(v.cc);
|
|
setCcb(v.ccb);
|
|
setWbKey(v.wbKey);
|
|
setWbRed(String(seedAdj.wbRed ?? 0));
|
|
setWbBlue(String(seedAdj.wbBlue ?? 0));
|
|
setFHigh(v.highlight);
|
|
setFShadow(v.shadow);
|
|
setFColor(v.color);
|
|
setFNr(v.nr);
|
|
setFSharp(v.sharp);
|
|
setFClarity(v.clarity);
|
|
setFEv(v.ev);
|
|
setName(initial?.name ?? '');
|
|
setIso(null);
|
|
}, [visible, currentBase, initial]);
|
|
|
|
// Switching the simulation reloads its standard values into the form — not
|
|
// in the editor though: there the numbers on screen are the user's own, and
|
|
// swapping the base look under them must not throw them away.
|
|
const selectSim = (b: BaseFilter) => {
|
|
setSim(b);
|
|
if (initial) return;
|
|
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);
|
|
setFColor(v.color);
|
|
setFNr(v.nr);
|
|
setFSharp(v.sharp);
|
|
setFClarity(v.clarity);
|
|
setFEv(v.ev);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const wb = WB_PRESETS.find((w) => w.key === wbKey);
|
|
const simAdj = FILM_SIMS.find((s) => s.baseFilter === sim)?.adjustments ?? DEFAULT_ADJUSTMENTS;
|
|
// The base is what the user was looking at (CREATE) or the recipe being
|
|
// edited. Spreading the sim's neutral values here instead — the old
|
|
// behaviour — silently zeroed every knob the form has no row for: exposure,
|
|
// contrast, vibrance, vignetting, glow, the WB red/blue shift. That is what
|
|
// made a saved recipe apply a different look from the one on screen.
|
|
const adj: ColorAdjustments = {
|
|
...DEFAULT_ADJUSTMENTS,
|
|
...seedAdj,
|
|
// A WB with no preset of its own keeps the value it was seeded with.
|
|
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),
|
|
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),
|
|
};
|
|
onSave({
|
|
name: name.trim() || initial?.name || 'NEW RECIPE',
|
|
baseFilter: sim,
|
|
adjustments: adj,
|
|
// An edit keeps the recipe's own frame and geotag choice; a fresh
|
|
// CREATE keeps the old defaults.
|
|
frameId: initial?.frameId ?? 'none',
|
|
useGeotag: initial?.useGeotag ?? true,
|
|
...(initial?.cinema ? { cinema: initial.cinema } : {}),
|
|
});
|
|
setName('');
|
|
onClose();
|
|
};
|
|
|
|
const fieldSets: { label: string; value: string; set: (s: string) => void; ph?: string }[] = [
|
|
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
|
|
{ label: 'SHADOW', value: fShadow, set: setFShadow },
|
|
{ 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 COMP.', value: fEv, set: setFEv, ph: 'EV' },
|
|
];
|
|
|
|
return (
|
|
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
|
|
<View className="absolute inset-0 bg-black/60" />
|
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
|
|
<View
|
|
style={{ paddingTop: insets.top + 12, paddingBottom: insets.bottom + 12, maxHeight: '100%' }}
|
|
className="bg-titan border-b border-zinc-800 rounded-b-3xl px-4 pt-1"
|
|
>
|
|
<View className="flex-row items-center justify-between mb-2">
|
|
<Text className="text-white font-mono text-sm font-bold tracking-wider">
|
|
{initial ? 'EDIT RECIPE' : 'CREATE RECIPE'}
|
|
</Text>
|
|
<TouchableOpacity onPress={onClose} className="px-2 py-1" activeOpacity={0.7}>
|
|
<Text className="text-zinc-400 font-mono text-xs font-bold">✕</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 8 }}>
|
|
{/* Name */}
|
|
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">NAME</Text>
|
|
<TextInput
|
|
value={name}
|
|
onChangeText={setName}
|
|
selectTextOnFocus
|
|
placeholder="Recipe name (e.g. Retro Film)"
|
|
placeholderTextColor="#71717a"
|
|
className="bg-carbon border border-zinc-800 rounded-lg p-2 text-white font-mono text-sm mb-2"
|
|
/>
|
|
|
|
{/* Simulation */}
|
|
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">SIMULATION</Text>
|
|
<ChipRow>
|
|
{FILM_SIMS.map((s) => (
|
|
<Chip key={s.id} label={s.name} active={sim === s.baseFilter} onPress={() => selectSim(s.baseFilter)} />
|
|
))}
|
|
</ChipRow>
|
|
|
|
{/* Dynamic range */}
|
|
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">DYNAMIC RANGE</Text>
|
|
<ChipRow>
|
|
{DR_OPTS.map((o) => (
|
|
<Chip key={o.v} label={o.d} active={dr === o.v} onPress={() => setDr(o.v)} />
|
|
))}
|
|
</ChipRow>
|
|
|
|
{/* Numeric textboxes — 2 per row */}
|
|
<View className="flex-row flex-wrap justify-between mt-1.5">
|
|
{fieldSets.map((f) => (
|
|
<NumRow key={f.label} label={f.label} value={f.value} onChange={f.set} placeholder={f.ph} />
|
|
))}
|
|
</View>
|
|
|
|
{/* Grain / chrome */}
|
|
<View className="mt-1">
|
|
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mb-0.5">GRAIN EFFECT</Text>
|
|
<ChipRow>
|
|
{GRAIN_OPTS.map((o) => (
|
|
<Chip key={o.v} label={o.d} active={grain === o.v} onPress={() => setGrain(o.v)} />
|
|
))}
|
|
{!GRAIN_OPTS.some((o) => o.v === grain) && (
|
|
<Chip label={grain} active onPress={() => {}} />
|
|
)}
|
|
</ChipRow>
|
|
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT</Text>
|
|
<ChipRow>
|
|
{CC_OPTS.map((o) => (
|
|
<Chip key={o.v} label={o.d} active={cc === o.v} onPress={() => setCc(o.v)} />
|
|
))}
|
|
</ChipRow>
|
|
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">COLOR CHROME EFFECT BLUE</Text>
|
|
<ChipRow>
|
|
{CC_OPTS.map((o) => (
|
|
<Chip key={o.v} label={o.d} active={ccb === o.v} onPress={() => setCcb(o.v)} />
|
|
))}
|
|
</ChipRow>
|
|
</View>
|
|
|
|
{/* White balance + R/B shift */}
|
|
<Text className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest mt-1.5 mb-0.5">WHITE BALANCE</Text>
|
|
<ChipRow>
|
|
{WB_PRESETS.map((w) => (
|
|
<Chip key={w.key} label={w.label} active={wbKey === w.key} onPress={() => setWbKey(w.key)} />
|
|
))}
|
|
{wbKey === 'custom' && (
|
|
<Chip label={`${seedAdj.temperature}K`} active onPress={() => setWbKey('custom')} />
|
|
)}
|
|
</ChipRow>
|
|
<View className="flex-row items-center mt-1">
|
|
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
|
|
WB SHIFT
|
|
</Text>
|
|
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">RED</Text>
|
|
<TextInput
|
|
value={wbRed}
|
|
onChangeText={setWbRed}
|
|
selectTextOnFocus
|
|
keyboardType="numbers-and-punctuation"
|
|
placeholder="-9..9"
|
|
placeholderTextColor="#52525b"
|
|
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right mr-3"
|
|
style={{ width: 52 }}
|
|
/>
|
|
<Text className="text-zinc-500 font-mono text-[9px] mr-1.5">BLUE</Text>
|
|
<TextInput
|
|
value={wbBlue}
|
|
onChangeText={setWbBlue}
|
|
selectTextOnFocus
|
|
keyboardType="numbers-and-punctuation"
|
|
placeholder="-9..9"
|
|
placeholderTextColor="#52525b"
|
|
className="bg-carbon border border-zinc-800 rounded-md px-2 py-1 text-white font-mono text-sm text-right"
|
|
style={{ width: 52 }}
|
|
/>
|
|
</View>
|
|
|
|
{/* ISO */}
|
|
<View className="flex-row items-center mt-1.5 mb-1">
|
|
<Text style={{ width: LABEL_W }} className="text-zinc-400 font-mono text-[9px] font-bold tracking-widest">
|
|
ISO
|
|
</Text>
|
|
{ISO_SUPPORTED ? (
|
|
<ChipRow>
|
|
<Chip label="AUTO" active={iso === null} onPress={() => setIso(null)} />
|
|
{ISO_STOPS.map((s) => (
|
|
<Chip key={s} label={String(s)} active={iso === s} onPress={() => setIso(s)} />
|
|
))}
|
|
</ChipRow>
|
|
) : (
|
|
<Chip label="AUTO (camera default)" active disabled onPress={() => {}} />
|
|
)}
|
|
</View>
|
|
</ScrollView>
|
|
|
|
<View className="flex-row space-x-3 mt-2">
|
|
<TouchableOpacity onPress={onClose} className="flex-1 bg-zinc-800 py-3 rounded-lg items-center" activeOpacity={0.7}>
|
|
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity onPress={handleSave} className="flex-1 bg-amber-500 py-3 rounded-lg items-center" activeOpacity={0.7}>
|
|
<Text className="text-black font-mono text-xs font-bold">{initial ? 'SAVE' : 'SAVE RECIPE'}</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
);
|
|
}
|