Files
RecipesCam/src/components/RecipeCreateModal.tsx
T
admin 97949e449d Ship LITE and PRO, and let CLARITY and LIGHT GLOW reach the library
Both flavors build from one JS tree: `lite` hands out the free apk
(com.locphamtran.recipescamera), `pro` sells as-is (same id + .pro, label
"RecipesCam Pro"). Release is signed from android/keystore.properties, both
files gitignored - lose either and no later build can install over this one.
The tier reaches JS through src/provariant.ts, which tools/set-variant.mjs
rewrites before each build (`npm run apk:lite` / `apk:pro`); the gradle flavor
alone would ship a Pro apk that thinks it is Lite.

The dev scaffolding goes: the three src/dev probes and their App effects, the
EXPO_PUBLIC_CAPTURE_MODE [CAPTURE] timing log, and the -PdevtestSuffix
side-by-side install hook. 'balanced' stays the shipped capture mode, which is
what those probes measured on the 12S Ultra.

Looking at the picture: WB now converts kelvin through the Planckian locus into
a luma-normalised gain (unity at 5500K, symmetric tint) instead of the old
one-sided push; LEITZ STREETLIFE splits into CLASSIC and VIVID; selecting a
recipe loads every knob it carries over the defaults and stays editable, and
RESET hands the panel back to the sim's own values. The in-app library picker,
the ultra-wide native module and recipe share/import land here too.

CLARITY and LIGHT GLOW existed only on the camera worklet and the export
engine, so a photo opened from the library ignored both. They now run in all
three library preview branches (libPhoto), and the preview clips them to the
drawn image - the export engine always clipped its bloom to the canvas, the
preview drew it over the whole rect, so the bloom bled past the photo onto the
black letterbox and over the frame's mat.
2026-09-14 19:48:44 +07:00

468 lines
19 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).
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: 8000, 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;
};
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, 0, 10),
sharpening: int(fSharp, simAdj.sharpening, 0, 10),
clarity: int(fClarity, simAdj.clarity, -10, 10),
grain: int(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: '0-10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '0-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. Fuji Retro)"
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>
);
}