Files
RecipesCam/src/components/AdjustmentPanel.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

1149 lines
44 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native';
import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Star, Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
import { FRAMES } from '../utils/frameUtils';
import { PRO_FRAMES } from '../utils/entitlement';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
import { grainPerInch } from '../utils/grainShader';
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
import RecipeCreateModal from './RecipeCreateModal';
// Compact bottom sheet above the tool rail:
// bottom row = the tool's chips (presets / parameters), horizontally scrollable
// rows above = one full-width slider per OPEN parameter, labeled with its name.
// Tapping a parameter chip toggles its slider row; chips stay visible.
// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels.
type TabId = 'recipes' | 'favorites' | 'iq' | 'wb' | 'filters' | 'frame';
// Choice-group keys that open a strip above the chip row: color chrome groups
// (wb tab) and the D.RANGE selector (iq tab).
type GroupKey =
| 'style'
| 'temp'
| 'grain'
| 'cx'
| 'cxb'
| 'dr'
| 'wmColor'
| 'wmSize'
| 'wmFont'
| 'gpsColor'
| 'gpsName'
| 'gpsTime'
| 'crop'
| 'rotate'
// Not a strip: the WATERMARK row REPLACES the frame chips (sub-panel).
| 'wm';
// The watermark's own option strips (TEXT COLOR / SIZE / FONT, GPS name+time).
// They hang OFF the sub-panel, so opening one must NOT close the sub-panel's
// chip row — that swap is what hid the edit chips behind the strip.
const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime'];
const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
interface AdjustmentPanelProps {
activeTab: TabId;
recipes: Recipe[];
currentRecipeId: string;
adjustments: ColorAdjustments;
// RESET: the app's own defaults, from any tab — the startup look, every
// parameter, the frame and the watermarks.
onResetAll: () => void;
// True while the look differs from a clean start: the RESET chip says so
// ("RESET *", amber) instead of silently doing nothing visible.
resetDirty: boolean;
selectedFrame: FrameId;
// FRAME tab CROP. Only meaningful on the library's plain frames (the camera
// composes through its ratio band, polaroid/wall through their own window),
// so App hides the chip everywhere else.
cropRatio: CropRatio;
onUpdateCropRatio: (ratio: CropRatio) => void;
// The ratio APPLY has committed; the strip offers APPLY only while the
// picked ratio is still uncommitted.
cropApplied?: CropRatio | null;
onApplyCrop: () => void;
cropEnabled?: boolean;
useGeotag: boolean;
// Runtime tier: PRO apk or a LITE apk that bought the unlock. A LITE build
// keeps every PRO chip on screen, greyed with its corner tag, so the panel
// has to read the value at render time rather than at build time.
pro: boolean;
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
// Settings sheet (gear between library picker and shutter) — no longer
// tool-rail tabs.
// The parameter whose slider row is open — lifted to App so it can also drive
// drag-anywhere-on-the-image adjustment while the row is open.
openParam: string | null;
onOpenParamChange: (key: string | null) => void;
// Base filter of the currently applied recipe — seeds the CREATE modal's
// SIMULATION row so quick-input starts from the active look.
currentBaseFilter?: BaseFilter;
// CREATE quick-input saves a brand-new recipe (name + adjustments from the
// modal) and applies it, unlike onSaveRecipe which snapshots the live state.
onCreateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
// Recipe being edited (custom ones only) — non-null puts the CREATE form
// into editor mode, prefilled with that recipe's name and parameters.
editingRecipe?: Recipe | null;
onCloseEdit: () => void;
// SAVE from the editor: the caller decides overwrite (same name) vs
// save-as (new name).
onUpdateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
onSelectRecipe: (recipe: Recipe) => void;
// FAVORITED tab + the star trailing every recipe chip. Ids only: the star is
// metadata, so it survives RESET and never changes the current look.
favoriteIds: string[];
onToggleFavorite: (id: string) => void;
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
onUpdateFrame: (frameId: FrameId) => void;
// FRAME tab: turn the inserted library photo by a quarter turn (0/90/180/
// 270deg clockwise) so it fits the chosen frame better, plus a fine
// STRAIGHTEN offset (-45..45deg) applied on top of that turn.
photoRotation: 0 | 90 | 180 | 270;
photoStraighten: number;
onPickRotation: (deg: 0 | 90 | 180 | 270) => void;
onStraighten: (deg: number) => void;
// FRAME-tab AUTO: ask App to read the tilt of the photo itself and set the
// STRAIGHTEN angle that cancels it. A one-shot, not a mode.
onAutoStraighten: () => void;
// FRAME tab (WALL FRAME only): hang the artwork landscape instead of the
// default portrait rotation.
wallLandscape: boolean;
onToggleWallLandscape: () => void;
onToggleGeotag: (enabled: boolean) => void;
onSaveRecipe: (name: string) => void;
// IMPORT: pick a recipe file shared from another phone and store it as a
// normal custom recipe.
onImportRecipe: () => void;
// LITE: this many of its own recipes exist, so the next one is the paid
// step. The strip's trailing + chip reads it, the save paths gate on it.
recipeLimitReached: boolean;
onRecipeLimit: () => void;
onDeleteRecipe: (id: string) => void;
// WATERMARK tab state — lifted to App so the live Viewfinder overlay, this
// input and the export options all read the same object.
customWm: CustomWatermark;
onUpdateCustomWm: (wm: CustomWatermark) => void;
// GPS mark styling — the SAME object the preview and the export read, so a
// chip press repaints both.
gpsWm: GpsWatermark;
onUpdateGpsWm: (wm: GpsWatermark) => void;
// ACCEPT: copy the live draft into the mark the export burns in. Edits stay a
// draft (viewfinder preview only) until this is pressed.
onAcceptCustomWm: () => void;
// CANCEL: throw the un-accepted draft away — back to the mark that was last
// ACCEPTED (or none at all) — and close the editor. The way out of a running
// watermark edit that previously did not exist.
onCancelCustomWm: () => void;
// Bumped by a double-tap on the mark: put the caret back in the text input.
wmEditNonce: number;
// WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270) applied to both
// the GPS lines and the custom text, in the preview and in the export.
wmRotation: number;
onRotateWm: () => void;
// FRAME → WATERMARK sub-panel is open: App needs this to arm the mark's
// drag/pinch layer on the image (the old `activeTab === 'watermark'`).
onWmPanelChange: (open: boolean) => void;
// BACK (undo) and PEEK (hold to compare) both work on the whole LOOK, not on
// one parameter, and both left this panel: BACK is a button in the top bar,
// PEEK is a hold on the picture itself (TopBar, Viewfinder).
}
interface ChipDef {
key: string;
label: string;
active: boolean;
onPress: () => void;
onPressDelete?: () => void; // custom presets carry an inline trash button
trash?: boolean;
star?: boolean; // trailing star button (recipes + FAVORITED)
starred?: boolean; // filled/amber star
onPressStar?: () => void;
disabled?: boolean; // informational label, not tappable
amberValue?: boolean; // amber text driven by edited value, not selection
pro?: boolean; // a PRO feature: LITE draws the corner tag on it
}
interface ParamDef {
key: string;
label: string;
value: number;
default: number;
min: number;
max: number;
step: number;
display: (v: number) => string;
onChange: (v: number) => void;
pro?: boolean; // PRO-only: the LITE build cannot open this row
}
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral
// 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say
// which chip is active — the last tapped preset is remembered to disambiguate.
const WB_PAIRS: Record<string, { t: number; ti: number }> = {
auto: { t: 5500, ti: 0 },
daylight: { t: 5500, ti: 0 },
'daylight-3r': { t: 5500, ti: -3 },
cloudy: { t: 6500, ti: 1 },
shade: { t: 7500, ti: 2 },
tungsten: { t: 3200, ti: 0 },
fluorescent: { t: 4000, ti: 3 },
};
// One list serves both the TEMP strip and the TEMP chip's label.
const WB_PRESETS: { v: string; d: string }[] = [
{ v: 'auto', d: 'AUTO' },
{ v: 'daylight', d: 'DAYLIGHT' },
{ v: 'daylight-3r', d: 'DAYLIGHT -3R' },
{ v: 'cloudy', d: 'CLOUDY' },
{ v: 'shade', d: 'SHADE' },
{ v: 'tungsten', d: 'TUNGSTEN' },
{ v: 'fluorescent', d: 'FLUOR' },
];
// WATERMARK tab styling choices: swatches for TEXT COLOR, multipliers on the
// base 3.2%-of-frame-width size for TEXT SIZE.
const WM_DEFAULT_COLOR = '#f59e0b';
const WM_COLORS: { v: string; d: string }[] = [
{ v: '#f59e0b', d: 'AMBER' },
{ v: '#ffffff', d: 'WHITE' },
{ v: '#111111', d: 'BLACK' },
{ v: '#ef4444', d: 'RED' },
{ v: '#3b82f6', d: 'BLUE' },
{ v: '#22c55e', d: 'GREEN' },
{ v: '#a1a1aa', d: 'GREY' },
];
const WM_SIZES: { v: string; d: string }[] = [
{ v: '0.6', d: 'S' },
{ v: '1', d: 'M' },
{ v: '1.6', d: 'L' },
{ v: '2.5', d: 'XL' },
];
export default function AdjustmentPanel({
activeTab,
recipes,
currentRecipeId,
adjustments,
onResetAll,
resetDirty,
selectedFrame,
cropRatio,
onUpdateCropRatio,
cropApplied,
onApplyCrop,
cropEnabled,
useGeotag,
pro,
openParam,
onOpenParamChange,
currentBaseFilter,
onCreateRecipe,
editingRecipe,
onCloseEdit,
onUpdateRecipe,
onSelectRecipe,
favoriteIds,
onToggleFavorite,
onUpdateAdjustments,
onUpdateFrame,
photoRotation,
photoStraighten,
onPickRotation,
onStraighten,
onAutoStraighten,
wallLandscape,
onToggleWallLandscape,
onToggleGeotag,
onImportRecipe,
recipeLimitReached,
onRecipeLimit,
onDeleteRecipe,
customWm,
onUpdateCustomWm,
gpsWm,
onUpdateGpsWm,
onAcceptCustomWm,
onCancelCustomWm,
wmEditNonce,
wmRotation,
onRotateWm,
onWmPanelChange,
}: AdjustmentPanelProps) {
// Double-tap on the mark in the viewfinder bumps wmEditNonce -> focus the
// input again so the text can be retyped after the keyboard was dismissed.
const wmInputRef = useRef<TextInput>(null);
useEffect(() => {
if (wmEditNonce > 0) {
// BACK hides the IME but leaves the input focused, so a bare focus() is a
// no-op and the keyboard never returns. Blur first so the re-focus is a
// real focus change the IME reacts to.
wmInputRef.current?.blur();
const id = setTimeout(() => wmInputRef.current?.focus(), 60);
return () => clearTimeout(id);
}
}, [wmEditNonce]);
// The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is
// open above the chips (same one-row-at-a-time pattern as openParam — which
// lives in App now, since an open row also arms image-drag adjustment).
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
// PRESETS tab only: RECIPES chip expands the user-recipe strip above the main
// row; CREATE chip opens the quick-input modal.
const [showRecipes, setShowRecipes] = useState(false);
const [createVisible, setCreateVisible] = useState(false);
// WB tab: preset last tapped while its engine pair is still in effect (AUTO
// vs DAYLIGHT share the neutral pair, so selection can't be derived).
const [wbChoice, setWbChoice] = useState<string | null>(null);
useEffect(() => {
setOpenGroup(null);
setShowRecipes(false);
setCreateVisible(false);
}, [activeTab]);
// The WATERMARK sub-panel is what arms the mark's drag layer on the image:
// one effect on the strip state keeps App's flag true exactly while it shows.
useEffect(() => {
onWmPanelChange(isWmPanel(openGroup));
}, [openGroup]);
// CROP is a plain-frame tool: the moment the selected frame drops it the chip
// unmounts, so the ratio strip must close with it (no orphan CROP row).
useEffect(() => {
if (!cropEnabled) setOpenGroup((g) => (g === 'crop' ? null : g));
}, [cropEnabled]);
// The strip a slider row was opened from: the "<" back button returns to it
// (STRAIGHTEN lives in ROTATE, COLOR TEMP in TEMP). Params without a parent
// strip just close the row.
const PARAM_GROUP: Partial<Record<string, GroupKey>> = {
straighten: 'rotate',
temperature: 'temp',
grain: 'grain',
grainSize: 'grain',
};
const toggleParam = (key: string) => {
haptic();
// One auxiliary row at a time: opening a slider closes any option strip.
setOpenGroup(null);
onOpenParamChange(openParam === key ? null : key);
};
const toggleGroup = (g: GroupKey) => {
haptic();
const next = openGroup === g ? null : g;
// Opening an options strip closes any open slider row and the expanded
// recipe strip (single-strip rule).
if (next) {
onOpenParamChange(null);
setShowRecipes(false);
}
setOpenGroup(next);
};
const a = adjustments;
const renderChip = (c: ChipDef) => {
const tone = c.active
? 'border-amber-500/70 bg-amber-500/15'
: 'border-zinc-700/60 bg-titan/80';
const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300';
// LITE only: corner tag on every chip whose look LITE cannot export.
const proTag = c.pro && !pro;
return (
<View
key={c.key}
className="mx-1"
onLayout={(e) => {
chipXRef.current[c.key] = e.nativeEvent.layout.x;
}}
>
{proTag && (
<View
pointerEvents="none"
className="absolute z-10 rounded-sm bg-amber-500 px-1"
style={{ top: -4, right: -4 }}
>
<Text className="font-mono text-[7px] font-bold text-black">PRO</Text>
</View>
)}
{c.trash || c.star ? (
<View className={`flex-row items-center rounded-full border py-1.5 pl-3 pr-1 ${tone}`}>
<TouchableOpacity onPress={() => { haptic(); c.onPress(); }} activeOpacity={0.7}>
<Text className={`font-mono text-[11px] font-bold ${textTone}`}>{c.label}</Text>
</TouchableOpacity>
{c.star && (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
haptic();
c.onPressStar && c.onPressStar();
}}
className="p-1.5 ml-1"
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Star
size={12}
color={c.starred ? '#f59e0b' : '#a1a1aa'}
fill={c.starred ? '#f59e0b' : 'none'}
/>
</TouchableOpacity>
)}
{c.trash && (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
haptic();
c.onPressDelete && c.onPressDelete();
}}
className="p-1.5 ml-1"
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Trash2 size={12} color="#ef4444" />
</TouchableOpacity>
)}
</View>
) : c.disabled ? (
<View className="mx-1 flex-row items-center rounded-full border border-zinc-800/80 bg-black/30 px-3 py-1.5">
<Text className="font-mono text-[10px] font-semibold text-zinc-500">{c.label}</Text>
</View>
) : (
<TouchableOpacity
onPress={() => { haptic(); c.onPress(); }}
className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`}
activeOpacity={0.7}
>
<Text className={`font-mono text-[11px] font-bold tracking-wide ${textTone}`}>
{c.label}
</Text>
</TouchableOpacity>
)}
</View>
);
};
// Continuous parameter chips: label + live value; amber text when edited;
// amber border while its slider row is open.
const paramChips = (params: ParamDef[]): ChipDef[] =>
params.map((p) => ({
key: p.key,
label: `${p.label.toUpperCase()} ${p.display(p.value)}`,
active: openParam === p.key,
amberValue: openParam !== p.key && p.value !== p.default,
// A PRO-only parameter stays visible in LITE as a greyed chip with its
// corner PRO tag — named where it is, which is the only place it can be.
pro: p.pro,
disabled: !!p.pro && !pro,
onPress: () => toggleParam(p.key),
}));
// Every tab carries the same RESET. It is pinned OUTSIDE the scroll row on
// purpose: the rows keep their scroll offset across tabs, so a chip inside
// one can sit off-screen just when it is wanted.
const resetChip = (): ChipDef => ({
key: 'reset-all',
// Trailing star + amber text: there is a change to undo (the chip is
// otherwise a plain neutral RESET that puts the code back to a clean start).
label: resetDirty ? 'RESET *' : 'RESET',
active: false,
amberValue: resetDirty,
onPress: () => {
// RESET is a parameter switch too: drop any open row/strip with it.
setOpenGroup(null);
if (openParam) onOpenParamChange(null);
onResetAll();
},
});
const choiceChips = (
keyPrefix: string,
options: { v: string; d: string; pro?: boolean }[],
current: string,
onPick: (v: string) => void
): ChipDef[] =>
options.map((o) => ({
key: `${keyPrefix}:${o.v}`,
label: o.d,
active: current === o.v,
pro: o.pro,
onPress: () => onPick(o.v),
}));
// ---- per-tab parameter definitions ---------------------------------------
// Static bounds/labels/accessors live in PARAM_DEFS (shared with App, which
// needs the same metadata to drive drag-anywhere adjustment); here each row
// is bound to the live adjustments value + an update callback.
const toRows = (defs: ContinuousParamDef[]): ParamDef[] =>
defs.map((d) => ({
key: d.key,
label: d.label,
value: d.get(a),
default: d.defaultValue,
pro: d.pro,
min: d.min,
max: d.max,
step: d.step,
display: d.display,
onChange: (v: number) => onUpdateAdjustments(d.set(v)),
}));
const paramDefs: Record<TabId, ParamDef[]> = {
recipes: [],
favorites: [],
iq: toRows(PARAM_DEFS.iq),
wb: toRows(PARAM_DEFS.wb).map((p) =>
p.key === 'temperature'
// Dragging COLOR TEMP is a hand-set balance: forget the preset so the
// TEMP chip falls back to the bare kelvin value.
? { ...p, onChange: (v: number) => { setWbChoice(null); p.onChange(v); } }
: p
),
filters: toRows(PARAM_DEFS.filters),
// FRAME carries one slider row: the fine STRAIGHTEN angle. The ROTATE
// strip's STRAIGHTEN option opens it, exactly like COLOR TEMP's.
frame: [
{
key: 'straighten',
label: 'STRAIGHTEN',
value: photoStraighten,
default: 0,
min: -45,
max: 45,
step: 0.5,
display: (v: number) => `${v > 0 ? '+' : ''}${v}°`,
onChange: (v: number) => onStraighten(v),
},
],
};
// TEMP chip's value: the remembered preset while the engine pair still
// matches it; otherwise the preset the pair maps to (neutral ⇒ AUTO); else
// the bare kelvin value — what COLOR TEMP dragged by hand lands on.
const wbValue = (): string => {
const t = a.temperature ?? 5500;
const ti = a.tint ?? 0;
if (wbChoice) {
const p = WB_PAIRS[wbChoice];
if (p && p.t === t && p.ti === ti) return wbChoice;
}
const hit = (Object.keys(WB_PAIRS) as string[]).find((k) => {
const p = WB_PAIRS[k];
return p.t === t && p.ti === ti;
});
return hit ?? `${t}K`;
};
// The chip is the only place the white balance is named, so it carries the
// value: TEMP AUTO / TEMP FLUOR / TEMP 4000K.
const wbTempLabel = (): string => {
const v = wbValue();
const preset = WB_PRESETS.find((o) => o.v === v);
return `TEMP ${preset ? preset.d : v}`;
};
const wbApply = (val: string) => {
setWbChoice(val);
// Choosing a preset is a deliberate look switch: collapse any open chrome
// options strip so it never lingers above the chips row.
setOpenGroup(null);
const p = WB_PAIRS[val] ?? WB_PAIRS.auto;
onUpdateAdjustments({ temperature: p.t, tint: p.ti });
};
// Choice-groups: one toggle chip sits in the chip row (COLOR CHROME /
// CHROME BLUE among the WB chips, D.RANGE after the LIGHT params); tapping it
// reveals a strip above the chip row with that group's options. Amber text
// while a value differs from the group's off value, amber border while its
// strip is open — same affordances as the parameter chips.
const chromeOptions = [
{ v: 'none', d: 'OFF' },
{ v: 'weak', d: 'WEAK' },
{ v: 'strong', d: 'STRONG' },
];
const drOptions = [
{ v: 'auto', d: 'AUTO' },
{ v: '100', d: 'DR100' },
{ v: '200', d: 'DR200' },
{ v: '400', d: 'DR400' },
];
// WATERMARK tab styling strips. FONT lists every family the PLATFORM font
// manager exposes (the device's own system fonts); DEFAULT keeps the face
// the mark shipped with.
const wmFontOptions = useMemo(() => {
// LITE ships a single basic face: the platform's own families (what
// DEFAULT is not) are the paid half of the FONT strip.
if (!pro) return [{ v: 'DEFAULT', d: 'DEFAULT' }];
const mgr = Skia.FontMgr.System();
const names = new Set<string>();
for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) {
const n = mgr.getFamilyName(i);
if (n && !/fallback/i.test(n)) names.add(n);
}
return [
{ v: 'DEFAULT', d: 'DEFAULT' },
...[...names].sort().map((n) => ({ v: n, d: n })),
];
}, [pro]);
const groupDefs: Record<Exclude<GroupKey, 'wm'>, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
// MONOCHROME GRAIN: the amount and the size of the clumps, then the count
// they add up to. The two knobs and their readout share one strip because
// they are one subject; the chip above them carries the amount, the way TEMP
// carries the kelvin.
grain: {
label: 'MONOCHROME GRAIN', off: '0',
// While a ruler is open its own entry is what the chip names, the way
// ROTATE's STRAIGHTEN does; otherwise the chip carries the amount, which is
// the one the strip is about.
value: openParam === 'grain' ? 'amount' : openParam === 'grainSize' ? 'size' : String(a.grain),
options: [
{ v: 'amount', d: 'AMOUNT' },
{ v: 'size', d: 'SIZE' },
],
onPick: (v) => toggleParam(v === 'size' ? 'grainSize' : 'grain'),
},
cx: {
label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions,
onPick: (v) => onUpdateAdjustments({ colorChrome: v as ColorAdjustments['colorChrome'] }),
},
cxb: {
label: 'CHROME BLUE', off: 'none', value: a.colorChromeBlue || 'none', options: chromeOptions,
onPick: (v) => onUpdateAdjustments({ colorChromeBlue: v as ColorAdjustments['colorChromeBlue'] }),
},
dr: {
label: 'D.RANGE', off: 'auto', value: String(a.dynamicRange ?? 'auto'), options: drOptions,
onPick: (v) =>
onUpdateAdjustments({
dynamicRange: (v === 'auto' ? 'auto' : parseInt(v, 10)) as ColorAdjustments['dynamicRange'],
}),
},
wmColor: {
label: 'TEXT COLOR', off: WM_DEFAULT_COLOR,
value: customWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS,
onPick: (v) => onUpdateCustomWm({ ...customWm, color: v }),
},
wmSize: {
label: 'TEXT SIZE', off: '1', value: String(customWm.size ?? 1), options: WM_SIZES,
onPick: (v) => onUpdateCustomWm({ ...customWm, size: Number(v) }),
},
wmFont: {
label: 'TEXT FONT', off: 'DEFAULT', value: customWm.font ?? 'DEFAULT',
options: wmFontOptions,
onPick: (v) => onUpdateCustomWm({ ...customWm, font: v === 'DEFAULT' ? null : v }),
},
// GPS mark's own features — one chip each, opening its options above (the
// same one-row-at-a-time strip the custom mark's styling uses).
gpsColor: {
label: 'COLOR', off: WM_DEFAULT_COLOR,
value: gpsWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS,
onPick: (v) => onUpdateGpsWm({ ...gpsWm, color: v }),
},
gpsName: {
label: 'PLACE NAME', off: 'ON',
value: gpsWm.showName === false ? 'OFF' : 'ON',
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
onPick: (v) => onUpdateGpsWm({ ...gpsWm, showName: v === 'on' }),
},
crop: {
label: 'CROP', off: 'none', value: cropRatio,
// 'free' keeps an explicit user-dragged rectangle; the rest center-crop
// the photo to the fixed shape the export writes.
options: [
{ v: 'none', d: 'NONE' },
{ v: 'free', d: 'FREE' },
{ v: '1:1', d: '1:1' },
{ v: '2:3', d: '2:3' },
{ v: '3:2', d: '3:2' },
{ v: '3:4', d: '3:4' },
{ v: '4:3', d: '4:3' },
{ v: '16:9', d: '16:9' },
],
onPick: (v) => onUpdateCropRatio(v as CropRatio),
},
// ROTATE: the quarter turns, the fine STRAIGHTEN slider and the AUTO read
// in one strip. Its RESET undoes this parameter only (turn + angle).
rotate: {
label: 'ROTATE', off: '0',
// 'straighten' while its slider row is open, so that chip highlights.
value: openParam === 'straighten' ? 'straighten' : String(photoRotation),
options: [
// The slider entry leads: the strip starts at offset 0, and this is the
// only option here that opens an editor.
{ v: 'straighten', d: 'STRAIGHTEN (±45°)' },
{ v: 'reset', d: 'RESET' },
{ v: 'auto', d: 'AUTO' },
{ v: '0', d: '0' },
{ v: '90', d: '90' },
{ v: '180', d: '180' },
{ v: '270', d: '270' },
],
onPick: (v) => {
if (v === 'reset') {
onPickRotation(0);
onStraighten(0);
} else if (v === 'straighten') {
toggleParam('straighten');
} else if (v === 'auto') {
// AUTO reads the photo and REPLACES the fine angle with the one
// that levels its dominant line, so pressing it twice is the same
// as pressing it once.
onAutoStraighten();
} else {
onPickRotation(Number(v) as 0 | 90 | 180 | 270);
}
},
},
// PHOTO STYLE: the eight film simulations, collapsed into one strip chip.
// Its value is the applied sim's id — or '' when a saved recipe is active
// (then the chip reads as "not the startup sim" and glows amber). The label
// carries no value on purpose: the top bar already names the active look.
style: {
label: 'PHOTO STYLE', off: 'sim-provia',
value: FILM_SIMS.some((s) => s.id === currentRecipeId) ? currentRecipeId : '',
options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name })),
onPick: (v) => {
const sim = FILM_SIMS.find((s) => s.id === v);
if (!sim) return;
// Same deliberate switch as the old inline chips: collapse the RECIPES
// strip so it never lingers over the freshly-applied sim.
setShowRecipes(false);
onSelectRecipe(filmSimToRecipe(sim));
},
},
// TEMP: the seven white-balance presets plus the COLOR TEMP slider, in a
// strip of their own. COLOR TEMP is not a value — picking it opens the
// slider row above (the chip keeps naming the kelvin value meanwhile).
temp: {
label: 'TEMP', off: 'auto', value: wbValue(),
options: [{ v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS],
onPick: (v) => (v === 'color-temp' ? toggleParam('temperature') : wbApply(v)),
},
gpsTime: {
label: 'TIME', off: 'ON',
value: gpsWm.showTime === false ? 'OFF' : 'ON',
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
onPick: (v) => onUpdateGpsWm({ ...gpsWm, showTime: v === 'on' }),
},
};
const groupChip = (g: Exclude<GroupKey, 'wm'>): ChipDef => ({
key: `grp-${g}`,
label: groupDefs[g].label,
active: openGroup === g,
amberValue: openGroup !== g && groupDefs[g].value !== groupDefs[g].off,
onPress: () => toggleGroup(g),
});
// TEMP is the one group whose chip shows its value (nothing else names it).
const tempChip = (): ChipDef => ({
...groupChip('temp'),
label: wbTempLabel(),
});
// GRAIN's chip names the amount the way TEMP names the kelvin: MONOCHROME
// GRAIN alone would not say whether any is dialled in, and the amount is what
// the strip is about. The SIZE and the count live inside the strip.
const grainChip = (): ChipDef => ({
...groupChip('grain'),
label: `MONOCHROME GRAIN ${a.grain % 1 ? a.grain.toFixed(1) : a.grain}`,
});
// ROTATE's chip names every part of the angle it applies: the turn and the
// fine angle AUTO (or the slider) left on it.
const rotateChip = (): ChipDef => {
const parts = [
`ROTATE ${photoRotation}`,
photoStraighten ? `${photoStraighten > 0 ? '+' : ''}${photoStraighten}°` : null,
];
return {
...groupChip('rotate'),
label: parts.filter(Boolean).join(' · '),
amberValue: openGroup !== 'rotate' && (photoRotation !== 0 || photoStraighten !== 0),
};
};
// WATERMARK is a sub-panel of FRAME now (the rail tab is gone): its chip swaps
// the frame chips for the watermark controls, behind a breadcrumb back.
const wmOn = useGeotag || customWm.enabled;
const wmChip = (): ChipDef => ({
key: 'grp-wm',
label: wmOn ? 'WATERMARK ON' : 'WATERMARK',
active: openGroup === 'wm',
amberValue: openGroup !== 'wm' && wmOn,
onPress: () => toggleGroup('wm'),
});
const wmChips = (): ChipDef[] => [
{
key: 'gps',
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
active: useGeotag,
pro: true,
onPress: () => onToggleGeotag(!useGeotag),
},
...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsName'), groupChip('gpsTime')] : []),
{
key: 'custom-wm',
label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF',
active: customWm.enabled,
onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }),
},
...(wmOn
? [{
key: 'wm-rotate',
label: `ROTATE ${wmRotation}°`,
active: wmRotation !== 0,
onPress: onRotateWm,
}]
: []),
...(customWm.enabled
? [
{ key: 'custom-wm-accept', label: 'ACCEPT', active: true, onPress: onAcceptCustomWm },
{ key: 'custom-wm-cancel', label: 'CANCEL', active: false, onPress: onCancelCustomWm },
groupChip('wmColor'),
groupChip('wmSize'),
groupChip('wmFont'),
]
: []),
];
// ---- per-tab chip rows (bottom, above the tool rail) -----------------------
// The rail rows keep their scroll offset across tabs, so a chip that was
// edited can sit off-screen on the tab you just came back to: every row
// scrolls to its first EDITED chip (amber), or to the start when none is.
// Offsets come from onLayout — x is already the content coordinate scrollTo
// wants — and land one frame after the new row has laid out. The watermark
// sub-panel swaps the frame row for another one, so it re-aims too (it used
// to inherit the frame row's offset, which pushed its chips off-screen).
const chipsScrollRef = useRef<ScrollView>(null);
const chipXRef = useRef<Record<string, number>>({});
const firstAmberChipRef = useRef<string | null>(null);
useEffect(() => {
const id = setTimeout(() => {
const key = firstAmberChipRef.current;
const x = key ? chipXRef.current[key] ?? 0 : 0;
chipsScrollRef.current?.scrollTo({ x: Math.max(0, x - 8), animated: false });
}, 80);
return () => clearTimeout(id);
}, [activeTab, isWmPanel(openGroup)]);
const chipRow = (chips: ChipDef[], padV = 6) => {
firstAmberChipRef.current = chips.find((c) => c.amberValue)?.key ?? null;
return (
<ScrollView
ref={chipsScrollRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: padV }}
>
{chips.map(renderChip)}
</ScrollView>
);
};
const chipsRow = (() => {
switch (activeTab) {
case 'recipes': {
// Main row: PHOTO STYLE (the eight film simulations, in a strip of its
// own), then the RECIPES toggle (expands the user/bundled recipe strip
// above) and CREATE (quick-input modal). Recipes themselves are no
// longer inline chips here.
return chipRow(
[
groupChip('style'),
{
key: 'recipes-toggle',
label: 'RECIPES',
active: showRecipes,
onPress: () => {
setOpenGroup(null);
setShowRecipes((v) => !v);
},
},
{
key: 'create',
label: 'CREATE',
active: false,
onPress: () => setCreateVisible(true),
},
{
key: 'import',
label: 'IMPORT',
active: false,
onPress: onImportRecipe,
},
],
8
);
}
case 'favorites': {
// FAVORITED: every starred recipe as a chip (star to un-star), newest
// look selectable in one tap. Ids whose recipe was deleted simply drop.
const favs = recipes.filter((r) => favoriteIds.includes(r.id));
return chipRow(
favs.length === 0
? [
{
key: 'no-favorites',
label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE',
disabled: true,
active: false,
onPress: () => {},
},
]
: favs.map((r) => ({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
star: true,
starred: true,
onPress: () => onSelectRecipe(r),
onPressStar: () => onToggleFavorite(r.id),
})),
8
);
}
case 'iq':
// LIGHT: continuous params on one row; D.RANGE is a toggle chip that
// opens its AUTO/DR100/DR200/DR400 strip above (same as the chrome
// groups on WB).
return chipRow([...paramChips(paramDefs.iq), groupChip('dr')]);
case 'wb':
return chipRow([
tempChip(),
...paramChips(paramDefs.wb.filter((p) => p.key !== 'temperature')),
...(['cx', 'cxb'] as const).map(groupChip),
]);
case 'filters':
// GRAIN is a strip of its own — amount, size, and the count they add up
// to — so it is one chip here and its two knobs live inside it; the chip
// keeps the place the knob had in this row.
return chipRow([
...paramChips(paramDefs.filters.filter((p) => p.key !== 'grain' && p.key !== 'grainSize')),
grainChip(),
]);
case 'frame':
// FRAME -> WATERMARK: the sub-panel row (breadcrumb back to the frames).
// It stays up while ANY watermark option strip is open — the strips are
// choices of the mark, not another panel.
if (isWmPanel(openGroup)) {
return chipRow(
[
{ key: 'wm-back', label: '< FRAME', active: false, onPress: () => setOpenGroup(null) },
...wmChips(),
],
8
);
}
return chipRow(
[
...choiceChips(
'fr',
FRAMES.map((f) => ({
v: f.id,
d: f.name.toUpperCase(),
pro: PRO_FRAMES.includes(f.id),
})),
selectedFrame,
(val) => {
// Picking another frame is another parameter: the open slider
// row (STRAIGHTEN) and the ROTATE strip go with the previous
// one — so the bubble-level line leaves the image too.
if (openParam) onOpenParamChange(null);
setOpenGroup(null);
onUpdateFrame(val as FrameId);
}
),
...(cropEnabled ? [groupChip('crop')] : []),
// APPLY commits the framed ratio: the viewer swaps the band (amber
// outline + dim surround) for the crop alone. Only while a fixed
// ratio is framed but not committed — RESET or another ratio is
// the way back to the framing state.
...(cropEnabled &&
cropRatio !== 'none' &&
cropRatio !== 'free' &&
cropApplied !== cropRatio
? [{ key: 'crop-apply', label: 'APPLY', active: true, onPress: onApplyCrop }]
: []),
rotateChip(),
...(selectedFrame === 'wallframe'
? [
{
key: 'wall-orient',
label: wallLandscape ? 'WALL LANDSCAPE' : 'WALL PORTRAIT',
active: wallLandscape,
onPress: onToggleWallLandscape,
},
]
: []),
wmChip(),
],
8
);
default:
return null;
}
})();
// ---- the single open slider row (ABOVE the chips row) ----------------------
const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null;
return (
<View>
{openParamDef && (
<View
key={openParamDef.key}
className="flex-row items-center border-b border-zinc-800/60 px-3 py-1.5"
>
<TouchableOpacity
onPress={() => {
// Back: close the row and bring back the strip it came from.
toggleParam(openParamDef.key);
const g = PARAM_GROUP[openParamDef.key];
if (g) setOpenGroup(g);
}}
className="mr-3 rounded-full border border-zinc-700 bg-titan px-3 py-2"
activeOpacity={0.7}
>
<Text className="font-mono text-xs font-bold text-zinc-300">&lt;</Text>
</TouchableOpacity>
<View className="flex-1">
<View className="flex-row justify-between items-center mb-0.5">
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-widest">
{openParamDef.label}
</Text>
<Text
className={`font-mono text-xs font-bold ${
openParamDef.value !== openParamDef.default ? 'text-amber-500' : 'text-zinc-300'
}`}
>
{openParamDef.display(openParamDef.value)}
</Text>
</View>
<Slider
value={openParamDef.value}
minimumValue={openParamDef.min}
maximumValue={openParamDef.max}
step={openParamDef.step}
onValueChange={openParamDef.onChange}
onSlidingComplete={haptic}
minimumTrackTintColor="#f59e0b"
maximumTrackTintColor="#27272a"
thumbTintColor="#f59e0b"
/>
</View>
</View>
)}
{openGroup && openGroup !== 'wm' && (
<View className="border-b border-zinc-800/60">
<ScrollView
key={openGroup}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{renderChip({
key: `grp-hint-${openGroup}`,
label: groupDefs[openGroup].label,
disabled: true,
active: false,
onPress: () => {},
})}
{choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick).map(
renderChip
)}
{/* GRAIN's third entry is a READOUT: the clump count the two knobs add
up to, in the print's own terms. Nothing to pick, so it is inert —
it moves when a knob moves. */}
{openGroup === 'grain' &&
renderChip({
key: 'grain-inch',
label: `${grainPerInch(a.grainSize ?? 100)}/INCH`,
disabled: true,
active: false,
onPress: () => {},
})}
</ScrollView>
</View>
)}
{/* PRESETS: expanded recipe strip (above the main chip row) while the
RECIPES chip is open — bundled defaults + user-created, custom
deletable inline. */}
{activeTab === 'recipes' && showRecipes && (
<View className="border-b border-zinc-800/60">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{recipes.length === 0
? renderChip({
key: 'no-recipes',
label: 'NO RECIPES YET - TAP CREATE',
disabled: true,
active: false,
onPress: () => {},
})
: recipes.map((r) =>
renderChip({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
star: true,
starred: favoriteIds.includes(r.id),
trash: !!r.isCustom,
onPress: () => onSelectRecipe(r),
onPressStar: () => onToggleFavorite(r.id),
onPressDelete: () => onDeleteRecipe(r.id),
})
)}
{/* The next recipe, right after the last one. Past the LITE limit
this is the only chip that cannot be pressed: it carries the
PRO tag and names the build instead of opening the form. */}
{renderChip({
key: 'add-recipe',
label: '+',
active: false,
pro: recipeLimitReached,
onPress: () => (recipeLimitReached ? onRecipeLimit() : setCreateVisible(true)),
})}
</ScrollView>
</View>
)}
{activeTab === 'frame' && openGroup === 'wm' && customWm.enabled && (
<View className="border-b border-amber-500/30 bg-amber-500/5 px-3 py-2">
<TextInput
ref={wmInputRef}
value={customWm.text}
onChangeText={(text) => onUpdateCustomWm({ ...customWm, text })}
placeholder="TYPE WATERMARK TEXT"
placeholderTextColor="#a1a1aa"
className="rounded-md border border-amber-500/70 bg-black/40 px-3 py-2 font-mono text-sm font-bold text-amber-300"
maxLength={80}
/>
</View>
)}
<View className="flex-row items-center">
{renderChip(resetChip())}
<View className="flex-1">{chipsRow}</View>
</View>
<RecipeCreateModal
visible={createVisible || !!editingRecipe}
currentBase={currentBaseFilter}
current={adjustments}
initial={editingRecipe ?? null}
onClose={() => {
setCreateVisible(false);
onCloseEdit();
}}
onSave={(recipe) => {
if (editingRecipe) onUpdateRecipe(recipe);
else onCreateRecipe(recipe);
setShowRecipes(true);
}}
/>
</View>
);
}