import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ToolRail, type TabId } from './ui/ToolRail'; import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn'; import { ImageStage } from './ui/ImageStage'; import { StraightenRuler } from './ui/StraightenRuler'; import { ToneCurvePanel } from './ui/ToneCurvePanel'; import { TopBar } from './ui/TopBar'; import { AuthModal } from './ui/AuthModal'; import { NameModal } from './ui/NameModal'; import { DeletePhotoModal } from './ui/DeletePhotoModal'; import { ClearPhotoModal } from './ui/ClearPhotoModal'; import { ExportModal } from './ui/ExportModal'; import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; import { loadEngine, MARK_FACES } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps'; import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session'; import { preloadSuperRes, upscaleJpeg } from './engine/superRes'; import { api, type Photo, type SavedRecipe, type User } from './api'; import { TIERS } from './config/tiers'; import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs'; import { exportRecipeXml, importRecipeXml } from '../shared/utils/recipeShare'; import { writeJpegExif } from '../shared/utils/exifWrite'; import { CROP_W_H, DEFAULT_CROP_RECT, type CropRatio, type CropRect, type FrameId, type GPSInfo, type GradientMask, type HealSpot, type HslBand, type HslBandId, type MosaicSpot, type Recipe, } from '../shared/types'; import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils'; import { grainPerInch, grainStockFor } from '../shared/utils/grainShader'; import { curveIsActive } from '../shared/utils/toneCurve'; import { HEAL_DEFAULT_R } from '../shared/utils/heal'; import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic'; import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask'; import type { MsgKey } from './i18n/vi'; // Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there. const MAX_PHOTOS = 12; // Whether the browser can be asked for the device's own position — a secure // origin on the desktop, https (or localhost) on the phone. This is the only // automatic source left for a frame iOS hands back with its EXIF stripped. const canLocate = typeof navigator !== 'undefined' && !!navigator.geolocation; // Preview renders on a downscaled copy; export always re-renders the original // bytes, so nothing shown here is baked into the file. const PREVIEW_MAX = 1600; // What a file name says its bytes are. The source state keeps only the bytes, // and the share sheet wants a type — an extension is all that is left of the // picker's own. Anything unlisted is a JPEG, which is what the camera hands over. const ORIGINAL_MIME = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', heic: 'image/heic', }; // A committed crop keeps only a fraction of the photo, so the 1600px copy // leaves too few pixels behind and the result reads as blocky once the stage // scales it up. While a crop is live the source is re-derived larger, inverted // by that fraction. The same ceiling bounds a zoomed-in stage, and 3200px is // what the decode can afford: past it a wheel gesture stops keeping up. const PREVIEW_HARD_MAX = 3200; const CROP_PREVIEW_MIN_FRAC = 0.05; // An export resamples a whole photo by up to this factor without the model. // Below it a plain filter and the model are the same picture and the filter // finishes in a second; above it (a small crop of a big photo) the model earns // the minutes it costs. Never a cap on the exported size itself. const MODEL_GAIN = 1.5; // Zoom asks for a bigger copy; quantised so a smooth gesture does not re-cut it // on every notch. const PREVIEW_QUANTUM = 512; const PREVIEW_DEBOUNCE_MS = 120; const FRAMES: { id: FrameId; label: MsgKey }[] = [ { id: 'none', label: 'frame.none' }, { id: 'polaroid', label: 'frame.polaroid' }, { id: 'classic-white', label: 'frame.classic' }, { id: 'cinematic', label: 'frame.cinematic' }, { id: 'wallframe', label: 'frame.wall' }, { id: 'wallframe-landscape', label: 'frame.wallLand' }, ]; // The recipe a saved photo's base is rendered through: the neutral stock — // every knob at its default and no film filter — so the render keeps the // geometry (rotation, straighten, crop) and leaves nothing of the look for the // studio to apply a second time when the photo is opened again. const BASE_RECIPE: Recipe = { id: 'base', name: 'BASE', isCustom: true, baseFilter: 'none', adjustments: { ...DEFAULT_ADJUSTMENTS }, useGeotag: false, }; // The printed frames are PRO, the plain export is not — the same split the // phone build draws (see src/utils/entitlement.ts there). Everything else the // gate owns lives where it is used: the GPS stamp in wmChips, the HDF effect in // paramDefs (its own `pro` flag), and the export tier in TIERS. const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe', 'wallframe-landscape']; // The three newest looks on the PHOTO STYLE strip are the account's own: the // pushed monochrome and the Leica rangefinder pair, which are the sims the // studio sells as the reason to prove the address. const PRO_SIMS: string[] = ['sim-bw-high-contrast', 'sim-leica', 'sim-leica-vivid']; // FREE draws the frame on the canvas and lets the user drag its corners, so // every ratio is offered here — no ratio is applied until APPLY. const CROPS: CropRatio[] = ['none', 'free', '1:1', '2:3', '3:2', '3:4', '4:3', '16:9']; // The seven white-balance presets, and the engine PAIR each one stands for: the // phone's own WB_PAIRS, kelvin AND tint, so the same chip prints the same frame // on both platforms. AUTO and DAYLIGHT share the neutral 5500K/0 pair (the // engine has no scene meter), which is why the chip is keyed by PRESET and not // by value — the pair alone cannot say which of the two is lit. const WB_PRESETS: { key: string; label: string; kelvin: number; tint: number }[] = [ { key: 'auto', label: 'AUTO', kelvin: 5500, tint: 0 }, { key: 'daylight', label: 'DAYLIGHT', kelvin: 5500, tint: 0 }, { key: 'daylight-3r', label: 'DAYLIGHT -3R', kelvin: 5500, tint: -3 }, { key: 'cloudy', label: 'CLOUDY', kelvin: 6500, tint: 1 }, { key: 'shade', label: 'SHADE', kelvin: 7500, tint: 2 }, { key: 'tungsten', label: 'TUNGSTEN', kelvin: 3200, tint: 0 }, { key: 'fluorescent', label: 'FLUOR', kelvin: 4000, tint: 3 }, ]; // Kelvin -> the CAST the ruler prints. Not the colour of a blackbody at that K: // the number names the light the scene was shot under, so the engine compensates // for it and WARMS the picture as K rises (kelvinToRGB). A swatch painted with // the light's own colour would therefore run the other way from the picture — // orange under a knob that blue-d the frame. Measuring the ruler means painting // what the render does: the engine's own gains on a mid grey, which is the same // matrix step the preview puts on every pixel, so this can never drift from it. function temperatureSwatch(k: number): string { const g = kelvinToRGB(k); const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v * 255))).toString(16).padStart(2, '0'); return `#${chan(0.5 * g.r)}${chan(0.5 * g.g)}${chan(0.5 * g.b)}`; } // HSL -> #rrggbb (CSS keeps hsl() opaque to a test, and the swatch prints its // own hex, so the panel hands out one format). function hslToHex(h: number, s: number, l: number): string { const k = (n: number) => (n + h / 30) % 12; const a = (s / 100) * Math.min(l / 100, 1 - l / 100); const chan = (n: number) => Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)))))) .toString(16) .padStart(2, '0'); return `#${chan(0)}${chan(8)}${chan(4)}`; } // The knobs that drive a colour get a swatch under their ruler, so the hue is // visible next to the number. TINT runs green (-10) through a neutral grey (0) // to magenta (+10) — the axis every WB tool labels G..M. const SLIDER_SWATCH: Partial string>> = { temperature: temperatureSwatch, tint: (v) => hslToHex(140 + ((v + 10) / 20) * 160, Math.abs(v) * 7, 58 + (10 - Math.abs(v)) * 3), }; // The option strips, one open at a time (phone's GroupKey set minus the groups // the web engine has no knob for). type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'grain' | 'wm' | 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'gpsFont'; const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont']; const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); // A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP). const PARAM_GROUP: Partial> = { temperature: 'temp', straighten: 'rotate', grain: 'grain', grainSize: 'grain', }; const CHROME_OPTIONS = [ { v: 'none', d: 'OFF' }, { v: 'weak', d: 'WEAK' }, { v: 'strong', d: 'STRONG' }, ]; const DR_OPTIONS = [ { v: 'auto', d: 'AUTO' }, { v: '100', d: 'DR100' }, { v: '200', d: 'DR200' }, { v: '400', d: 'DR400' }, ]; const WM_COLORS = [ { v: '#ffffff', d: 'WHITE' }, { v: '#f59e0b', d: 'AMBER' }, { v: '#000000', d: 'BLACK' }, ]; const WM_SIZES = [ { v: '1', d: '1x' }, { v: '1.5', d: '1.5x' }, { v: '2', d: '2x' }, ]; // FONT's list: the faces the bundle carries (see engine/assets.ts). The phone // lists the device's own families through the platform font manager; a browser // has no font service, so the honest list is what we ship. const WM_FONTS = [ { v: 'DEFAULT', d: 'DEFAULT' }, ...MARK_FACES.map((f) => ({ v: f.family, d: f.family.toUpperCase() })), ]; interface LoadedSource { bytes: Uint8Array; name: string; url: string; } // The custom watermark's own geometry. x/y are fractions of the stamp the // engine draws on (x = left edge of the text, y = its baseline), size is the // multiplier of the base face — 3.2% of the stamp width. The drag box on the // photo reads and writes exactly these three numbers, so what the box encloses // is what the file gets. type MarkStyle = { color: string; size: number; x: number; y: number; font: string | null }; const DEFAULT_MARK_STYLE: MarkStyle = { color: '#ffffff', size: 1, x: 0.5, y: 0.92, font: null }; // GPS carries the same three geometry numbers plus its two switches, and its // box on the photo reads and writes them the same way the custom mark's does. type GpsStyle = { color: string; showName: boolean; showTime: boolean; size: number; x: number; y: number; font: string | null }; const DEFAULT_GPS_STYLE: GpsStyle = { color: '#f59e0b', showName: true, showTime: true, size: 1.2, x: 0.06, y: 0.7, font: null }; // Where each mark's first row sits under y, and the icon column GPS prints // before its text — the engine's own em values, mirrored from the box that // measures them (see ImageStage's WM_* constants). const CUSTOM_BASELINE_EM = 0.35; const GPS_BASELINE_EM = 1.1; const GPS_GAP_EM = 1.6; // Everything a single UNDO step puts back. The recipe alone was not enough: // frame, crop, rotation, marks and geotag all change the photo too. interface Look { recipe: Recipe; simId: string | null; frameId: FrameId; crop: CropRatio; cropRect: CropRect; cropApplied: boolean; rotation: 0 | 90 | 180 | 270; straighten: number; markOn: boolean; markText: string; markStyle: MarkStyle; useGeotag: boolean; gpsStyle: GpsStyle; } export function Workspace() { const { t } = useI18n(); // Session restore: read once, synchronously, so the first paint already holds // the user's own settings instead of a flash of the defaults. The photo comes // back from IndexedDB in the boot effect below. const session = useMemo(loadState, []); const [engine, setEngine] = useState<{ assets: RenderAssets; gpu: boolean } | null>(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); const [source, setSource] = useState(null); const [previewBytes, setPreviewBytes] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); // COMPARE: a second render of the same frame through the neutral stock, so // the split is a look against no-look and never a frame against a photo — // rotation, straighten, crop and the frame all land on both halves by // construction. Only asked for while the toggle is on. const [comparing, setComparing] = useState(false); const [compareUrl, setCompareUrl] = useState(null); const [recipe, setRecipe] = useState(() => ({ ...DEFAULT_RECIPES[0], id: 'web-current', name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA', ...session.recipe, })); const [simId, setSimId] = useState(() => session.simId ?? null); const [frameId, setFrameId] = useState(() => session.frameId ?? 'none'); const [crop, setCrop] = useState(() => session.crop ?? 'none'); const [cropRect, setCropRect] = useState(() => session.cropRect ?? DEFAULT_CROP_RECT); const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false); const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null); const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0); // FRAME's STRAIGHTEN: a fine angle on top of the quarter turn. Same pixels, // so the photo's own dimensions never change and the crop stays valid. const [straighten, setStraighten] = useState(() => session.straighten ?? 0); const [openParam, setOpenParam] = useState(null); // The HSL mixer: which hue band the ruler edits, whether the eyedropper is // armed, and the last colour it read off the photo. const [hslBand, setHslBand] = useState('red'); const [picking, setPicking] = useState(false); // FX's brush: which of its two tools holds the pointer on the photo (null for // neither), the size each one lays spots down at — each keeps its own, since // a speck of dust and a face are nothing like the same circle — and, read back // off the recipe, the spots already laid, so the photo, UNDO and the saved // look all speak about the same lists. const [brushTool, setBrushTool] = useState<'heal' | 'mosaic' | null>(null); const [healR, setHealR] = useState(HEAL_DEFAULT_R); const [mosaicR, setMosaicR] = useState(MOSAIC_DEFAULT_R); // FX's gradient masks: which shape the next drag on the photo lays down (null // for neither), the masks the recipe holds, and which of them is chosen — the // one whose shape is on the photo and whose knobs are in the panel beside it. const [maskTool, setMaskTool] = useState<'linear' | 'radial' | null>(null); const [maskSel, setMaskSel] = useState(null); const masks = recipe.adjustments.masks ?? []; // The one mask the column beside the chips speaks about: its shape is on the // photo and its knobs are in the panel. Null while none is chosen. const selMask = maskSel !== null ? masks[maskSel] ?? null : null; const healSpots = recipe.adjustments.heal ?? []; const mosaicSpots = recipe.adjustments.mosaic ?? []; const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null); // Where on the photo that colour was read: the mixer's panel hangs there. const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null); const [openGroup, setOpenGroup] = useState(null); // The TEMP preset the user last tapped. AUTO and DAYLIGHT are the same pair on // the engine, so the pair alone cannot say which chip is lit — the phone keeps // the same memory (wbChoice in AdjustmentPanel). const [wbChoice, setWbChoice] = useState(null); const [showRecipes, setShowRecipes] = useState(false); const [tab, setTab] = useState('presets'); const [peek, setPeek] = useState(false); const [gps, setGps] = useState(() => session.gps ?? null); // ISO off the loaded photo's EXIF; null means the strip prints AUTO. const [iso, setIso] = useState(null); // When the loaded photo was taken, off its own EXIF. The GPS stamp prints this // and not the clock, whichever way the position was come by: a photo from the // library can be years old, and the location typed in for it today is still the // location of that day. Null for a file with no date, which then falls back to // the visitor's clock — there is nothing else to believe. const [capturedAt, setCapturedAt] = useState(null); const [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' }); const [locating, setLocating] = useState(false); // Once the visitor turns the prompt down, a photo without GPS stops asking on // its own: the panel's button still is there for a deliberate second try. const locDeniedRef = useRef(false); const [place, setPlace] = useState(() => session.place ?? ''); const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false); // A session stored before GPS could be dragged carries only the colour and // the two switches: the box's own numbers fall back to the defaults. const [gpsStyle, setGpsStyle] = useState(() => ({ ...DEFAULT_GPS_STYLE, ...session.gpsStyle })); const [markText, setMarkText] = useState(() => session.markText ?? ''); const [markOn, setMarkOn] = useState(() => session.markOn ?? false); // A session stored before the mark could be dragged carries only color/size: // the box's own position falls back to the default instead of going undefined. const [markStyle, setMarkStyle] = useState(() => ({ ...DEFAULT_MARK_STYLE, ...session.markStyle })); // Every knob the workspace holds, written back on change — the reload half of // the session. Small JSON, so no debounce is worth the risk of losing the last // edit; the photo has its own store (engine/session.ts). useEffect(() => { saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); }, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); // Crop has three states: none, framed-but-uncommitted (the frame is draggable // and the whole photo is shown), and applied (the crop alone is rendered). // What the stage says it is painting at; PREVIEW_MAX until it reports. const [zoomNeedPx, setZoomNeedPx] = useState(PREVIEW_MAX); const onStageZoom = useCallback((longDevicePx: number) => { setZoomNeedPx( Math.max(PREVIEW_MAX, Math.min(PREVIEW_HARD_MAX, Math.ceil(longDevicePx / PREVIEW_QUANTUM) * PREVIEW_QUANTUM)) ); }, []); const cropEditing = crop !== 'none' && !cropApplied; const cropLive = crop !== 'none' && cropApplied; // A committed crop keeps only part of the photo, so the 1600px copy leaves // too few pixels behind and the stage shows a blocky upscale. While the crop // is live the source is re-derived larger — 1/f, where f is the crop's // longest side as a fraction of the photo's, capped so the decode stays // bounded. cropRect is in the rotated frame, so the shape is swapped first. const cropLongFrac = (() => { const d = photoDims; const turn = rotation % 180 !== 0; const f = Math.max( (turn ? d?.h ?? 0 : d?.w ?? 0) * cropRect.w, (turn ? d?.w ?? 0 : d?.h ?? 0) * cropRect.h ); const long = Math.max(d?.w ?? 0, d?.h ?? 0); return long > 0 ? f / long : Math.min(cropRect.w, cropRect.h); })(); // The two sizes an export is measured against: the photo as it arrived, and // the photo as the stage stands (a committed crop keeps only part of it). The // export menu names the second one, and asks the model to invent the pixels // when the target is bigger than the photo itself. const sourceLongest = photoDims ? Math.max(photoDims.w, photoDims.h) : 0; // The crop's own long edge: the frame's shape decides which side of the // rotated photo it eats, so the fraction above (not the wider of the two // rect sides) is what scales the photo's longest edge down. A 1:1 crop of a // 4:3 frame keeps full height, and this is what keeps the export from // believing the crop is still 1600px across. const editedLongest = Math.max(1, Math.round(sourceLongest * (cropLive ? cropLongFrac : 1))); // The same number as a pair, for the row under the photo: the file's shape // turned by the quarter turn (a 90° copy swaps the sides) and cut by a // committed crop. A live crop is not the photo yet, so it does not move the // readout — APPLY does. STRAIGHTEN never changes the size, it fits the // rotated rectangle inside the same pixels. const stageDims = (() => { if (!photoDims || !photoDims.w || !photoDims.h) return null; const turn = rotation % 180 !== 0; const w = turn ? photoDims.h : photoDims.w; const h = turn ? photoDims.w : photoDims.h; return cropLive ? { w: Math.round(w * cropRect.w), h: Math.round(h * cropRect.h) } : { w, h }; })(); const cropWH = crop !== 'none' && crop !== 'free' ? CROP_W_H[crop] : null; const previewMax = Math.min( PREVIEW_HARD_MAX, Math.max( zoomNeedPx, cropLive ? Math.max(PREVIEW_MAX, Math.round(PREVIEW_MAX / Math.max(cropLongFrac, CROP_PREVIEW_MIN_FRAC))) : PREVIEW_MAX ) ); // --- undo / redo ---------------------------------------------------------- // The stack holds whole looks. remember() reads a ref written during render, // so it is stable and always snapshots the state as of the last painted // frame — the same value the user is looking at when they press the button. // The floor is the look the session loaded with; REDO holds what UNDO walked // back over, up to the newest edit, and a fresh edit drops that branch. const [undo, setUndo] = useState([]); const [redo, setRedo] = useState([]); // Which look of the open frame's own history is standing on the stage, if one // was picked from its column; a fresh edit or another frame clears it. const [histPick, setHistPick] = useState(null); // FX's MONOCHROME switch remembers what the photo was wearing, so flipping it // back returns that filter — and its sim name — instead of guessing PROVIA. const monoPrev = useRef<{ baseFilter: Recipe['baseFilter']; simId: string | null; name: string } | null>(null); const lookRef = useRef(null); lookRef.current = { recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle }; // Twenty looks is enough to walk, but the oldest entry is the floor — the look // the session loaded with — and is never the one trimmed, so UNDO always // reaches the start of the session. const pushUndo = (snap: Look) => setUndo((stack) => (stack.length > 19 ? [stack[0], ...stack.slice(-18), snap] : [...stack, snap])); const remember = useCallback(() => { const snap = lookRef.current; if (!snap) return; pushUndo(snap); setRedo([]); // The look on the stage is no longer the one that was picked, so the // history column drops its mark. setHistPick(null); }, []); const restoreLook = useCallback((l: Look) => { setRecipe(l.recipe); setSimId(l.simId); setFrameId(l.frameId); setCrop(l.crop); setCropRect(l.cropRect); setCropApplied(l.cropApplied); setRotation(l.rotation); setStraighten(l.straighten); setMarkOn(l.markOn); setMarkText(l.markText); setMarkStyle(l.markStyle); setUseGeotag(l.useGeotag); setGpsStyle(l.gpsStyle); }, []); const defaultLook = useCallback((): Look => { const provia = FILM_SIMS.find((f) => f.id === 'sim-provia'); const cur = lookRef.current?.recipe ?? DEFAULT_RECIPES[0]; return { recipe: { ...(provia ? filmSimToRecipe(provia) : cur), id: cur.id, name: cur.name, isCustom: true }, simId: provia ? provia.id : null, frameId: 'none', crop: 'none', cropRect: DEFAULT_CROP_RECT, cropApplied: false, rotation: 0, straighten: 0, markOn: false, markText: '', markStyle: { ...DEFAULT_MARK_STYLE }, useGeotag: false, gpsStyle: { ...DEFAULT_GPS_STYLE }, }; }, []); const [exporting, setExporting] = useState(false); // The export menu: the sizes are picked before the render starts, because a // target bigger than the photo has a model run behind it first. const [choosingExport, setChoosingExport] = useState(false); const [savingPhoto, setSavingPhoto] = useState(false); const [user, setUser] = useState(null); // PRO is the old "signed in", and then some: the account has to have proven // the address it signed up with. Until it does, this build serves it exactly // like a guest — watermarked 2048px export, no saving, no PRO tools. An // allowlisted admin is trusted by construction and counts as verified. const pro = !!user?.verified; // Admins are the server's own operators: the API skips MAX_PHOTOS_PER_USER // for them (server.ts), so the studio stops counting at the same place. null // means "no cap" all the way down to SAVE PHOTO's label. const photoCap = user?.admin ? null : MAX_PHOTOS; // What the stamp's name line prints: the visitor's own PLACE NAME wins over // whatever the fix carried, because neither a phone's position nor a photo's // EXIF ever brings one, and typing it after locating must still show up. // Memoised: this object is a dependency of the preview and the export, and a // fresh one on every render would re-arm the render effect with its own // output — the stage would repaint itself forever and the panel would stop // answering clicks. const gpsInfo = useMemo(() => (gps ? { ...gps, locality: place.trim() || gps.locality } : null), [gps, place]); const [saved, setSaved] = useState([]); // Bumped by RESET while CREATE is open: the form above the button listens for // it and drops back to its defaults. const [createReset, setCreateReset] = useState(0); // This account's own folder. The list drives the MY PHOTOS tab, which reopens // a saved frame with the settings it was stored with; the count labels the // save button. The API is the real cap — this only labels the button. const [myPhotos, setMyPhotos] = useState([]); // The saved photo standing on the stage, if any — its own consent chip shows. const [openPhotoId, setOpenPhotoId] = useState(null); // Recipes made in this session, guest ones included. A guest's copy lives // here and nowhere else, so reloading the page is what deletes it. const [created, setCreated] = useState([]); // Which save flow is asking for a name, if any: `photo` on the first filing // of an uploaded frame, `recipe` on SAVE RECENT. One modal, two callers. const [naming, setNaming] = useState<'photo' | 'recipe' | null>(null); // The saved photo whose removal is being confirmed, if any — its own history // goes with it, so the dialog says so and offers a download first. const [removing, setRemoving] = useState(null); // CLEAR's confirmation is up: the photo is still on the stage until one of its // answers is taken. const [clearing, setClearing] = useState(false); // null = closed. /app?auth=1 opens the studio's dialog straight on the sign-in // tab (sign-in is the default; ?auth=signup asks for the other one); the // landing page runs the same dialog in place instead of linking here. // `verify` is the third face of the same dialog: the account exists but its // address is unproven, so the only thing left to do is open the letter. const [authMode, setAuthMode] = useState<'login' | 'signup' | 'verify' | null>(() => { const asked = new URLSearchParams(window.location.search).get('auth'); return asked ? (asked === 'signup' ? 'signup' : 'login') : null; }); // What every PRO-only entry point calls instead of returning silently: a // guest is sent to the dialog for an account, an unverified account to the // same dialog for its letter. const promptPro = useCallback(() => setAuthMode(user ? 'verify' : 'login'), [user]); // The banner's own button. The address already has one live link, so this // replaces it — the API caps how often, and answers either way. const [resent, setResent] = useState(false); const resendVerification = useCallback(async () => { try { await api.resendVerification(); setResent(true); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [t]); const renderToken = useRef(0); // Which previewMax the bytes in previewBytes were made at, so the upgrade // effect does not redo the load-time 1600px copy. const previewMaxRef = useRef(PREVIEW_MAX); const previewJobRef = useRef(0); // --- boot ----------------------------------------------------------------- useEffect(() => { let alive = true; loadEngine() .then((loaded) => { if (!alive) return; setEngine(loaded); if (!loaded.gpu) setError(t('err.noWebgl')); }) .catch((err) => alive && setError(String(err))); api .me() .then((r) => alive && setUser(r.user)) .catch(() => undefined); // signed out is a valid state — the demo needs no account // ...and the photo half of the session, back out of IndexedDB. loadPhoto() .then((photo) => { if (!alive || !photo) return; return adoptPhoto( photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true ); }) .catch(() => undefined); return () => { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { // PRO, not merely signed in: those two listings are the API's own, and it // answers an unverified account with a 403 (see requirePro there). if (!pro) { setSaved([]); setMyPhotos([]); return; } api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([])); api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([])); }, [pro]); // --- preview render ------------------------------------------------------- // A new photo has nothing to compare yet: the toggle goes off with it, so the // compare copy is never a stale render of the last one. useEffect(() => { setComparing(false); }, [source]); useEffect(() => { if (!engine || !previewBytes) return; const token = ++renderToken.current; const timer = setTimeout(async () => { setBusy(t('act.exporting')); try { // The geometry is the frame's own; only the recipe differs between the // render and the compare copy, so the two can never drift apart. const frame = cropEditing ? 'none' : frameId; const options = { wallframeLandscape: frameId === 'wallframe-landscape', sharpen: true, photoRotation: rotation, photoStraighten: straighten, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, ...markStyle } : null, gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, }; const out = await renderPhoto({ source: previewBytes, recipe, // An uncommitted crop is framed on the bare photo, so the overlay // measures exactly what the engine will cut. frameId: frame, useGeotag, gpsInfo, assets: engine.assets, options, }); if (token !== renderToken.current) return; if (!out) throw new Error('render returned null'); setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' })); }); if (!comparing) { setCompareUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); return; } const before = await renderPhoto({ source: previewBytes, recipe: BASE_RECIPE, frameId: frame, useGeotag, gpsInfo, assets: engine.assets, options, }); if (token !== renderToken.current || !before) return; setCompareUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([before.bytes as BlobPart], { type: 'image/jpeg' })); }); } catch (err) { if (token === renderToken.current) setError(t('err.generic', { msg: String(err) })); } finally { if (token === renderToken.current) setBusy(null); } }, PREVIEW_DEBOUNCE_MS); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing]); // The source copy behind previewBytes, re-made only when previewMax moves: // applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back. useEffect(() => { if (!source || previewMaxRef.current === previewMax) return; const job = ++previewJobRef.current; resizedJpeg(source.bytes, previewMax) .then((bytes) => { if (job !== previewJobRef.current) return; previewMaxRef.current = previewMax; setPreviewBytes(bytes); }) .catch(() => undefined); }, [source, previewMax]); // --- input --------------------------------------------------------------- // One photo, two ways in: a drop/pick (which also stores it for the next // visit) and the session restore, whose params are already in state and so // must not be overwritten by the photo's own EXIF. // A frame arriving on the stage replaces whatever the preview was holding. // The old render is thrown away FIRST, so the new one can never be painted // over it: the buffer is empty before the load starts. const clearPreview = useCallback(() => { setPreviewUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); }, []); // CLEAR: the photo leaves the stage, the look stays. The knobs, the sim, the // frame and the stamps are the user's work and were made on this picture, not // by it — so they survive it, exactly as they survive opening another photo. // What goes is what the picture itself brought: its pixels, its shape, its // EXIF position and ISO. The session's own copy goes too (forgetPhoto), or // the next reload would put the frame straight back. const clearPhoto = useCallback(() => { setSource((prev) => { if (prev) URL.revokeObjectURL(prev.url); return null; }); clearPreview(); setPreviewBytes(null); setPhotoDims(null); setOpenPhotoId(null); setGps(null); setGpsManual({ lat: '', lng: '' }); setUseGeotag(false); setIso(null); setError(null); setBusy(null); void forgetPhoto(); }, [clearPreview]); // What the phone app gets from the OS geocoder, the browser has no twin of, // so the name behind a coordinate comes from the API. Fire and forget: the // stamp is already readable without it, and a name that arrives late (or not // at all) must never hold up the picture. const resolvePlace = useCallback((latitude: number, longitude: number) => { void api .place(latitude, longitude) .then(({ place: found }) => { if (!found) return; // Only the fix still on screen is worth naming — the user may have // moved on to another photo while the lookup was in flight. setGps((prev) => (prev && prev.latitude === latitude && prev.longitude === longitude ? { ...prev, locality: found } : prev)); }) .catch(() => undefined); }, []); // Every position that reaches the stage still without a name is named here, // once: a photo restored from the session lands at boot, before /me has // answered, so the account reading that put it there saw `pro` as false — and // a guest's photo earns its name the moment they sign in. PRO only, and a // position the user has named by hand is left alone. useEffect(() => { if (pro && gps && !gps.locality) resolvePlace(gps.latitude, gps.longitude); }, [pro, gps, resolvePlace]); // iPhone Safari returns a frame shot through the in-page camera with no EXIF // at all (WebKit bug 207088), so the device's own position is the only // automatic source left. Tried the moment such a photo lands (see adoptPhoto) // and again on the panel's own button, for a permission that was refused or // dismissed the first time. Secure origin only, which the check above gates. // The caller may know the photo's own date and this callback not: the automatic // try below happens inside adoptPhoto, whose render still holds the previous // photo's date in state. So the date is handed in when it is known, and only a // photo that carries none falls back to the clock. const locateMe = useCallback((when?: number | null) => { if (!navigator.geolocation) { setError(t('wm.locFail')); return; } setLocating(true); navigator.geolocation.getCurrentPosition( (pos) => { const { latitude, longitude } = pos.coords; setGpsManual({ lat: latitude.toFixed(6), lng: longitude.toFixed(6) }); setGps({ latitude, longitude, locality: place, timestamp: when ?? capturedAt ?? Date.now(), source: 'device' }); setUseGeotag(true); setLocating(false); }, () => { setLocating(false); locDeniedRef.current = true; setError(t('wm.locFail')); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 } ); }, [place, t, capturedAt]); const adoptPhoto = useCallback( async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => { const preview = await resizedJpeg(bytes, PREVIEW_MAX); const found = await readGps(bytes); const foundIso = await readIso(bytes); // The photo's own date, held apart from its GPS: a frame with a date but no // position still stamps the date when the position is typed in or asked of // the device (see the two places the clock is fallen back on, below). const taken = await readCapturedAt(bytes); setCapturedAt(taken); // The photo's own shape drives the default crop frame, and its own pixels // are what an export is measured against — a 1600px copy would make a // 4000px photo look like it needed inventing pixels it already has. Read // off the original, not the preview. const dims = await createImageBitmap(new Blob([bytes as BlobPart])) .then((bmp) => { const d = { w: bmp.width, h: bmp.height }; bmp.close(); return d; }) .catch(() => null); setPhotoDims(dims); setSource((prev) => { if (prev) URL.revokeObjectURL(prev.url); return { bytes, name, url: URL.createObjectURL(blob) }; }); // The new photo's copy is the 1600px one; a live crop or a zoomed stage // then upgrades it. previewMaxRef.current = PREVIEW_MAX; setZoomNeedPx(PREVIEW_MAX); previewJobRef.current++; setPreviewBytes(preview); setGps(found); setIso(foundIso); // A new photo starts a new history whose floor is the default look, so // UNDO walks all the way back and still leaves the photo loaded. REDO has // nothing to walk forward onto in a history that just began. setUndo([defaultLook()]); setRedo([]); if (keepGeo) return; setUseGeotag(!!found); if (!found) setGpsManual({ lat: '', lng: '' }); // A frame the phone handed over without EXIF gets one automatic try at the // device's position, so the GPS stamp is there without a second tap. PRO // only: the stamp is a PRO tool, and a guest's photo is not worth a // location prompt. if (!found && pro && canLocate && !locDeniedRef.current) locateMe(taken); }, [defaultLook, pro, locateMe] ); const loadFile = useCallback( async (file: File) => { setError(null); setBusy(t('auth.busy')); // A file from the disk is nobody's saved frame: SAVE PHOTO must add one, // not overwrite whichever of the folder's own was open before. Opening one // of those sets the id back, after this call. setOpenPhotoId(null); try { const bytes = await fileToBytes(file); await adoptPhoto(bytes, file.name, file); void savePhoto(file.name, bytes); } catch { setError(t('err.decode')); } finally { setBusy(null); } }, [t, adoptPhoto] ); useEffect(() => { const onPaste = (e: ClipboardEvent) => { const file = e.clipboardData?.files?.[0]; if (file) void loadFile(file); }; window.addEventListener('paste', onPaste); return () => window.removeEventListener('paste', onPaste); }, [loadFile]); // --- recipe edits --------------------------------------------------------- const applyRecipe = useCallback( (next: Recipe) => { remember(); setRecipe(next); setSimId(next.isCustom ? null : next.id); // A recipe filed by SAVE RECENT carries the frame it was saved with; // a bundled preset carries none, and then the frame on the stage stays. const stored = (next as Recipe & { frameId?: FrameId }).frameId; if (stored) setFrameId(stored); }, [remember] ); const setAdjustment = useCallback( (patch: Partial) => { setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...prev.adjustments, ...patch } })); }, [] ); // A slider drag is one edit, not one per pixel: the first move after the // ruler opened takes the snapshot, the rest ride on it. const sliderEditRef = useRef(false); useEffect(() => { sliderEditRef.current = false; }, [openParam]); // The watermark's drag box rides the same one-edit-per-gesture rule as a knob: // a whole drag is one undo step, not one per pointer move. The ref clears // whenever the FRAME panel changes, which is when the box leaves the photo. const wmEditRef = useRef(false); // The colour wells drag the same way: one undo step per visit to the picker, // not one per shade the slider under it passes through. const wmColorRef = useRef(false); useEffect(() => { wmEditRef.current = false; wmColorRef.current = false; }, [openGroup]); const setAdjustmentOnce = useCallback( (patch: Partial) => { if (!sliderEditRef.current) { sliderEditRef.current = true; remember(); } setAdjustment(patch); }, [remember, setAdjustment] ); // FX's HEAL brush writes the spots the same way a knob writes its value: one // gesture is one undo step whatever it laid down, so UNDO takes the dust back // out a stroke at a time, and CLEAR — one step — hands the whole photo back. // Nothing is capped: the shader is built to carry exactly the spots the // recipe holds (shared/utils/heal.ts), so the repair made first is still // there after the hundredth. const addHealSpots = useCallback( (spots: HealSpot[]) => { if (!spots.length) return; remember(); const list = lookRef.current?.recipe.adjustments.heal ?? []; setAdjustment({ heal: [...list, ...spots] }); }, [remember, setAdjustment] ); const clearHealSpots = useCallback(() => { remember(); // The field is what "this photo was healed" means, so CLEAR drops it rather // than leaving an empty list behind. setAdjustment({ heal: undefined }); }, [remember, setAdjustment]); // FX's MOSAIC brush writes its spots the same way, one gesture to one step. // Nothing is capped here either (shared/utils/mosaic.ts), and every spot is // laid down: unlike a repair, a hidden patch cannot be refused — there is no // wrong light to borrow from, only cells. const addMosaicSpots = useCallback( (spots: MosaicSpot[]) => { if (!spots.length) return; remember(); const list = lookRef.current?.recipe.adjustments.mosaic ?? []; setAdjustment({ mosaic: [...list, ...spots] }); }, [remember, setAdjustment] ); const clearMosaicSpots = useCallback(() => { remember(); // Same as HEAL's CLEAR: dropping the field is what "nothing is hidden" // means, so no empty list is left behind. setAdjustment({ mosaic: undefined }); }, [remember, setAdjustment]); // Whichever tool is armed owns the pointer, its own size and its own list — // one callback pair for the stage, chosen here, so the layer below never has // to know which tool it is painting for. const brushSpots: (HealSpot | MosaicSpot)[] = brushTool === 'mosaic' ? mosaicSpots : healSpots; const brushR = brushTool === 'mosaic' ? mosaicR : healR; const setBrushR = brushTool === 'mosaic' ? setMosaicR : setHealR; const addBrushSpots = useCallback( (spots: (HealSpot | MosaicSpot)[]) => { if (brushTool === 'mosaic') addMosaicSpots(spots as MosaicSpot[]); else addHealSpots(spots as HealSpot[]); }, [brushTool, addMosaicSpots, addHealSpots] ); // Moving or deleting a repair already on the photo is a knob being turned // rather than a stroke being laid: the stage hands back the list the photo now // holds, and says whether this change is the one the gesture started with — // that first one is remembered, so a drag is a single undo step however many // moves it took, and a press that only chose a spot records nothing at all. An // empty list drops the field, the same way CLEAR does. const editBrushSpots = useCallback( (spots: (HealSpot | MosaicSpot)[], undo: boolean) => { if (undo) remember(); const list = spots.length ? spots : undefined; if (brushTool === 'mosaic') setAdjustment({ mosaic: list as MosaicSpot[] | undefined }); else setAdjustment({ heal: list as HealSpot[] | undefined }); }, [brushTool, remember, setAdjustment] ); // FX's two gradient masks are a list the user draws rather than a knob, but // they are written the same way the brush's spots are: a shape dragged onto // the photo is one undo step, and the knobs on the chosen shape are one more // however far they travel — the first move of a drag takes the snapshot and // the rest ride on it. An empty list drops the field, the way CLEAR does. const editMasks = useCallback( (list: GradientMask[], undo: boolean) => { if (undo) remember(); setAdjustment({ masks: list.length ? list : undefined }); setMaskSel(list.length ? Math.min(maskSel ?? 0, list.length - 1) : null); }, [maskSel, remember, setAdjustment] ); const addMask = useCallback( (mask: GradientMask) => { remember(); const list = lookRef.current?.recipe.adjustments.masks ?? []; setAdjustment({ masks: [...list, mask] }); // The shape just drawn is the one being edited: its handles and its knobs // are how the drag that made it is finished. setMaskSel(list.length); }, [remember, setAdjustment] ); const deleteMask = useCallback(() => { if (maskSel === null) return; remember(); const list = masks.filter((_, i) => i !== maskSel); setAdjustment({ masks: list.length ? list : undefined }); setMaskSel(null); }, [masks, maskSel, remember, setAdjustment]); const clearMasks = useCallback(() => { remember(); setAdjustment({ masks: undefined }); setMaskSel(null); }, [remember, setAdjustment]); // One knob of the chosen mask, through the same one-edit-per-gesture door as // every other slider. EXP is the spec's own EV (-5..+5); CON and SAT are the // app's -10..+10, which the shader turns into the spec's -1..+1. const setMaskKnob = useCallback( (patch: Partial) => { if (maskSel === null) return; const list = masks.slice(); const cur = list[maskSel]; if (!cur) return; list[maskSel] = { ...cur, ...patch }; setAdjustmentOnce({ masks: list }); }, [masks, maskSel, setAdjustmentOnce] ); // A mask that leaves the list — RESET, or an older file loaded over it — takes // the selection with it: the knobs must never speak about a shape the photo no // longer holds. useEffect(() => { if (maskSel !== null && maskSel >= masks.length) setMaskSel(null); }, [maskSel, masks.length]); // The knobs of a freshly drawn or newly chosen mask are a new gesture: the // next drag takes its own snapshot instead of riding on the drawing one. useEffect(() => { sliderEditRef.current = false; }, [maskSel]); // FRAME's STRAIGHTEN rides the same one-edit-per-gesture rule as a knob, so // dragging the ruler is one undo step instead of one per degree. const setStraightenOnce = useCallback( (v: number) => { if (!sliderEditRef.current) { sliderEditRef.current = true; remember(); } setStraighten(Math.max(-180, Math.min(180, Math.round(v)))); }, [remember] ); // The mark's box on the photo writes through one door: dragging moves x/y, // the corner handle moves size, and both go the same way a knob does — one // undo step for the whole gesture. const setWatermarkPos = useCallback( (patch: Partial>) => { if (!wmEditRef.current) { wmEditRef.current = true; remember(); } setMarkStyle((prev) => ({ ...prev, ...patch })); }, [remember] ); // GPS's box writes through the same door: dragging moves x/y, the corner // handle moves size, the whole gesture is one undo step. const setGpsPos = useCallback( (patch: Partial>) => { if (!wmEditRef.current) { wmEditRef.current = true; remember(); } setGpsStyle((prev) => ({ ...prev, ...patch })); }, [remember] ); // The colour well on either watermark's strip: a free colour, one undo step // for as long as the panel stays open. const setWmColor = useCallback( (color: string) => { if (!wmColorRef.current) { wmColorRef.current = true; remember(); } if (openGroup === 'gpsColor') setGpsStyle((prev) => ({ ...prev, color })); else setMarkStyle((prev) => ({ ...prev, color })); }, [remember, openGroup] ); // One band of the mixer, read and written through one door: the tab's ruler // and the panel hung on the photo are the same three knobs. An all-zero band // is dropped, so an untouched band never lands in the recipe and a band put // back to zero leaves no trace. const setBandKnob = useCallback( (which: 0 | 1 | 2, v: number) => { const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); const next: HslBand = [band[0], band[1], band[2]]; next[which] = Math.round(v); const bands = { ...recipe.adjustments.hslBands }; if (next[0] === 0 && next[1] === 0 && next[2] === 0) delete bands[hslBand]; else bands[hslBand] = next; setAdjustmentOnce({ hslBands: bands }); }, [recipe.adjustments.hslBands, hslBand, setAdjustmentOnce] ); // The mixer's overall move: one door for the three image-wide knobs, so the // ruler and the FIELDS it writes are the same values whatever opened it. const setHslGlobal = useCallback( (which: 0 | 1 | 2, v: number) => { const key = which === 0 ? 'hslHue' : which === 1 ? 'hslSat' : 'hslLum'; setAdjustmentOnce({ [key]: Math.max(-10, Math.min(10, Math.round(v))) }); }, [setAdjustmentOnce] ); // --- crop ----------------------------------------------------------------- // Picking a ratio only opens the frame; the crop is committed by APPLY. const pickCrop = useCallback( (ratio: CropRatio) => { remember(); setCrop(ratio); setCropApplied(false); if (ratio === 'none' || ratio === 'free' || !photoDims) return; // Largest centred rect of that shape the photo can hold. CROP_W_H is a // PIXEL aspect; in fractions (of the photo's width/height) it becomes // R = ratio / (w/h), so a 3:4 frame lands on a 3:4 photo and a square one // on anything else. const k = rotation % 180 === 0 ? photoDims.w / photoDims.h : photoDims.h / photoDims.w; const R = CROP_W_H[ratio] / k; const w = R >= 1 ? 1 : R; const h = R >= 1 ? 1 / R : 1; setCropRect({ x: (1 - w) / 2, y: (1 - h) / 2, w, h }); }, [remember, photoDims, rotation] ); const applyCrop = useCallback(() => setCropApplied(true), []); // One step back: the look standing now goes onto REDO and the stack's top // comes back. CANCEL and the UNDO button are the same move. const stepBack = useCallback(() => { const last = undo[undo.length - 1]; const cur = lookRef.current; if (!last || !cur) return; restoreLook(last); setUndo((stack) => stack.slice(0, -1)); setRedo((stack) => [...stack.slice(-19), cur]); }, [undo, restoreLook]); // CANCEL throws the framing away — the pick pushed the look it replaced, so // one undo is exactly "never mind". const cancelCrop = useCallback(() => stepBack(), [stepBack]); const reset = useCallback(() => { remember(); // Back to the startup look, exactly like the phone: the PROVIPES sim, every // knob neutral, no frame/crop, no rotation, both marks off and restyled. restoreLook(defaultLook()); setOpenParam(null); setOpenGroup(null); setShowRecipes(false); }, [remember, restoreLook, defaultLook]); // UNDO walks back to the look the session loaded with; REDO walks forward // again over exactly what it stepped past, up to the newest edit. const undoLast = useCallback(() => stepBack(), [stepBack]); const redoLast = useCallback(() => { const next = redo[redo.length - 1]; const cur = lookRef.current; if (!next || !cur) return; restoreLook(next); setRedo((stack) => stack.slice(0, -1)); setUndo((stack) => [...stack, cur]); }, [redo, restoreLook]); // SAVE RECENT — its own tab, not a corner of CREATE RECIPES. The look standing // on the stage (sim, WB, light, FX and the frame) is filed under a name this // account has not used; the frame rides in the recipe's own JSON, so applying // the entry puts the whole look back, frame included. const saveRecipeNamed = useCallback( async (name: string) => { try { const look: Recipe & { frameId?: FrameId } = { ...recipe, name, frameId: cropEditing ? 'none' : frameId }; const { recipe: stored } = await api.createRecipe(name, look); setSaved((list) => [stored, ...list]); setRecipe((prev) => ({ ...prev, name })); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [recipe, frameId, cropEditing, t] ); const saveRecipe = useCallback(() => { if (!pro) { promptPro(); return; } setNaming('recipe'); }, [pro, promptPro]); // A recipe name is only free while this account has not spent it. Checked on // the field rather than at the store: the list is already here, whole. const recipeNameTaken = useCallback( (name: string): string | null => saved.some((entry) => entry.name.trim().toLowerCase() === name.toLowerCase()) ? t('name.duplicate') : null, [saved, t] ); const deleteRecipeEntry = useCallback( async (entry: SavedRecipe) => { try { await api.deleteRecipe(entry.id); setSaved((list) => list.filter((r) => r.id !== entry.id)); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [t] ); // FAVORITED holds the account's own recipes. `*`, or dragging a recipe chip // onto the star in the rail, files the look on the stage there; a guest is // asked to sign in first, like every other write in the studio. const favoriteRecipe = useCallback( async (entry: Recipe) => { if (!pro) { promptPro(); return; } try { const stored = await api.createRecipe(entry.name, { ...entry }); setSaved((list) => [stored.recipe, ...list]); setTab('favorited'); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [pro, promptPro, t] ); // The star reads the id the dragged chip carried back to its recipe. const favoriteById = useCallback( (id: string) => { const entry = [...created, ...DEFAULT_RECIPES].find((r) => r.id === id); if (entry) void favoriteRecipe(entry); }, [created, favoriteRecipe] ); // `*` files the look on the stage without leaving the tab it was built in. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== '*' || e.ctrlKey || e.metaKey || e.altKey) return; const el = e.target as HTMLElement | null; if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return; e.preventDefault(); void favoriteRecipe(recipe); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [favoriteRecipe, recipe]); // CREATE tab: a new recipe is applied at once and listed under RECIPES. A // PRO account also stores it; a guest's — or an unverified account's — copy // stays in `created`, which is exactly what "forgotten when you leave" means. const addCreatedRecipe = useCallback( async (draft: RecipeDraft) => { const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true }; setCreated((list) => [entry, ...list]); applyRecipe(entry); if (!pro) return; try { const stored = await api.createRecipe(entry.name, entry); setSaved((list) => [stored.recipe, ...list]); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [pro, applyRecipe, t] ); // CREATE edits the photo live: each knob lands on the render as it is typed so // the recipe can be judged before saving. No undo entry — this is a draft, and // SAVE/EXPORT is what commits it. const previewCreatedRecipe = useCallback((draft: Pick) => { setRecipe((prev) => ({ ...prev, ...draft, isCustom: true })); }, []); // The phone's encrypted .recipe envelope (`shared/utils/recipeShare` is the // app's own file, byte-identical). PRO only — a guest's recipe, or an // unverified account's, is never written to disk. const exportRecipeFile = useCallback( (draft: RecipeDraft) => { if (!pro) { promptPro(); return; } const xml = exportRecipeXml({ ...draft, id: 'share' }); download( new TextEncoder().encode(xml), `${(draft.name || 'recipe').replace(/\W+/g, '-').toLowerCase()}.recipe`, 'application/xml' ); }, [pro, promptPro] ); // IMPORT is the other half of EXPORT: the phone's .recipe file, or one saved // from another account. `shared/utils/recipeShare` is the app's own parser, so // the look arrives intact — a guest gets it for the session, like CREATE. const importRecipeFile = useCallback( async (file: File) => { setError(null); try { const parsed = importRecipeXml(await file.text()); const entry: Recipe = { ...parsed, id: `import-${Date.now()}`, isCustom: true }; setCreated((list) => [entry, ...list]); applyRecipe(entry); setFrameId(parsed.frameId ?? 'none'); setUseGeotag(!!parsed.useGeotag); setShowRecipes(true); } catch (err) { setError(t('err.generic', { msg: String(err) })); } }, [applyRecipe, t] ); // The picker is built on the spot: a second in the page // would compete with the stage's own for `input[type=file]`. const pickRecipeFile = useCallback(() => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.recipe,application/xml,text/xml'; input.onchange = () => { const file = input.files?.[0]; if (file) void importRecipeFile(file); }; input.click(); }, [importRecipeFile]); // The strip this look shows — the tagline off the recipe name, and the // technical line off the camera (EXIF ISO, or AUTO) plus the recipe's own // grain, warmth and monochrome state. Metadata only: an exported file keeps // clean pixels (the strip rides EXIF), and a saved photo stores it so the // landing reel can frame it the same way. const strip = useMemo(() => { const name = (recipe.name || 'RECIPE').trim(); const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase(); // The amount is on half steps now, so the tag prints the half it was set to // instead of rounding a 3.5 to a 4 the frame does not carry. const grainValue = Math.max(0, recipe.adjustments.grain ?? 0); const grain = Number.isInteger(grainValue) ? String(grainValue) : grainValue.toFixed(1); const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250); const tail = isMonochromeBase(recipe.baseFilter) ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`; return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` }; }, [recipe.name, recipe.adjustments, recipe.baseFilter, iso]); // Everything needed to put this photo back on the stage: the recipe plus the // frame and geotag the stage held. EXIF and a saved photo both carry it. const photoSettings = useMemo( () => ({ ...recipe, frameId: cropEditing ? 'none' : frameId, useGeotag }), [recipe, frameId, cropEditing, useGeotag] ); // The look the stage is holding, judged against the PRO list. The chips keep // a guest from picking one, but a look can arrive without them: an imported // .recipe, or a photo saved before the gate existed. The FILE is where the // gate bites — the preview is free, exactly as it is on the phone. const proLookInUse = useCallback( () => PRO_FRAMES.includes(frameId) || PRO_SIMS.includes(simId ?? '') || useGeotag || (recipe.adjustments.hdf ?? 0) > 0 || // The mixer, on either of its two axes: a band's HUE/SAT/LUM, or the // image-wide three that sit under the IMAGE divider. Object.keys(recipe.adjustments.hslBands ?? {}).length > 0 || (recipe.adjustments.hslHue ?? 0) !== 0 || (recipe.adjustments.hslSat ?? 0) !== 0 || (recipe.adjustments.hslLum ?? 0) !== 0, [ frameId, simId, useGeotag, recipe.adjustments.hdf, recipe.adjustments.hslBands, recipe.adjustments.hslHue, recipe.adjustments.hslSat, recipe.adjustments.hslLum, ] ); // `true` when a file was handed over, so CLEAR knows the export it offered // actually happened before the photo comes off the stage. const exportPhoto = useCallback(async (target: number | null): Promise => { if (!engine || !source) return false; if (!pro && proLookInUse()) { promptPro(); return false; } setExporting(true); setError(null); try { const tier = TIERS[pro ? 'user' : 'guest']; // The menu's answer, under the tier's ceiling. UNCHANGED is `null`. const wanted = target === null ? null : Math.min(target, tier.maxExportDim ?? target); // How far the photo's own long edge must be stretched for the CROP to // land on `wanted`: the render keeps only `editedLongest` of the photo. const needLongest = wanted === null ? sourceLongest : Math.round((sourceLongest * wanted) / editedLongest); let src = source.bytes; if (wanted === null) { // The tier's ceiling is the only thing that still resizes, and only // downwards: this is the export the button did before it had a menu. if (tier.maxExportDim && sourceLongest > tier.maxExportDim) { src = await resizedJpeg(source.bytes, tier.maxExportDim, 0.95); } } else if (wanted > sourceLongest || needLongest > sourceLongest * MODEL_GAIN) { // The photo is short of the pixels being asked for — more so once the // crop is counted — so the model invents them here, on the device; the // picture never leaves it. The whole photo is enlarged by the factor the // crop will cut it back by, so the file lands on `wanted` exactly. src = await upscaleJpeg(source.bytes, needLongest, ({ done, total }) => setBusy(t('act.upscaling', { done, total })) ); } else if (needLongest !== sourceLongest) { // The photo already holds every pixel the export asks for, so there is // nothing to invent: resample the whole photo (down, or a hair up to // make up for the crop) and let the render cut the crop out of it. This // is what keeps a 2400px photo exportable at 2K in a second. src = await scaledJpeg(source.bytes, needLongest); } const out = await renderPhoto({ source: src, recipe, // The file is the preview: an uncommitted frame is not exported. frameId: cropEditing ? 'none' : frameId, useGeotag, gpsInfo, assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', dpi: 300, sharpen: true, photoRotation: rotation, photoStraighten: straighten, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, ...markStyle } : tier.watermark ? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 } : null, gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); // Encode → JFIF density 300 (inside renderPhoto) → EXIF rebuild, the same // order the app uses. The source photo donates its camera/lens tags; the // recipe is stamped as metadata, never burned into the pixels. const bytes = writeJpegExif( out.bytes, { dateTime: new Date(), gps: gps ? { latitude: gps.latitude, longitude: gps.longitude } : null, width: out.width, height: out.height, dpi: 300, locationName: place || gps?.locality || null, imageDescription: strip.tag, userComment: JSON.stringify(photoSettings), }, source.bytes ); const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`); return true; } catch (err) { setError(t('err.generic', { msg: String(err) })); return false; } finally { setExporting(false); // The upscale's own progress line goes with the export that wrote it. setBusy(null); } }, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]); // SAVE PHOTO: the stage as it stands, uploaded to this account's own folder. // The upload is left clean (no burned strip) so the landing reel can draw its // own `.lp-shot` frame around it; the labels are stored alongside instead. // The 12-photo cap is the API's, mirrored here so the button can say no. // `true` once the row and its base are stored, so CLEAR only takes the photo // off the stage when the save it offered really landed. const savePhotoToFolder = useCallback(async (photoName?: string): Promise => { if (!pro) { promptPro(); return false; } if (!engine || !source) { setError(t('save.photoNoSource')); return false; } // Re-saving the frame already on the stage replaces its own row, so the cap // only stands in the way of a new one. const open = myPhotos.find((p) => p.id === openPhotoId); if (!open && photoCap !== null && myPhotos.length >= photoCap) { setError(t('save.photoFull', { max: photoCap })); return false; } // A frame being filed for the first time is asked for a name — the folder // keeps it, and the landing reel draws it as the frame's title. A re-save // keeps the name it already has, so it never asks twice. if (!open && photoName === undefined) { setNaming('photo'); return false; } // A re-save keeps the name the frame already has — the folder's title, not // the recipe it happens to be standing in. const labels = { ...strip, title: open ? open.title ?? strip.title : (photoName ?? strip.title) }; setSavingPhoto(true); setError(null); try { const src = TIERS.user.maxExportDim ? await resizedJpeg(source.bytes, TIERS.user.maxExportDim, 0.95) : source.bytes; const out = await renderPhoto({ source: src, recipe, frameId: cropEditing ? 'none' : frameId, useGeotag, gpsInfo, assets: engine.assets, options: { wallframeLandscape: frameId === 'wallframe-landscape', dpi: 300, sharpen: true, photoRotation: rotation, photoStraighten: straighten, cropRect: cropLive ? cropRect : null, watermark: markOn && markText ? { text: markText, ...markStyle } : null, gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null, }, }); if (!out) throw new Error('render returned null'); // The pixels under the look: the same geometry, none of the grade, frame // or stamp. The recipe does not carry the geometry, so it has to stay in // these pixels — everything else is what reopening re-applies, and // applying it to the render is what doubled the frame and the look. const base = await renderPhoto({ source: src, recipe: BASE_RECIPE, frameId: 'none', assets: engine.assets, options: { dpi: 300, // The live preview sharpens once on top of this, so the base is left // unsharpened — the render the folder shows carries its own. sharpen: false, photoRotation: rotation, photoStraighten: straighten, cropRect: cropLive ? cropRect : null, }, }); if (!base) throw new Error('render returned null'); const file = new File([out.bytes as BlobPart], `${strip.tag.slice(1).toLowerCase() || 'photo'}.jpg`, { type: 'image/jpeg', }); let savedId: number; if (open) { // The frame on the stage is one of the folder's own: it keeps its id and // its place, and the look it carried before this save lands in history. // Its consent is the one it already had — a re-save is not a re-answer. const { photo } = await api.replacePhoto(open.id, file, labels, { recipe: photoSettings, consent: open.consent !== false, }); setMyPhotos((prev) => prev.map((p) => (p.id === open.id ? { ...p, ...photo } : p))); savedId = open.id; } else { const { photo } = await api.uploadPhoto(file, labels, { recipe: photoSettings }); // The list entry carries the recipe we just sent, so reopening works // before the next /photos/mine refresh. setMyPhotos((prev) => [{ ...photo, recipe: photoSettings }, ...prev]); setOpenPhotoId(photo.id); savedId = photo.id; } // The render is what the folder shows; this base is what the studio opens. // It has to land with the save, so a failure fails the save. await api.putPhotoBase(savedId, base.bytes); // The stage now stands on the newest look of this frame, so no earlier // one in its history is the picked one. setHistPick(null); return true; } catch (err) { setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) })); return false; } finally { setSavingPhoto(false); } }, [pro, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]); // CLEAR's "keep it" answers: the save lands and the photo goes, or the export // hands a file over and then it goes. Neither forces the hand — a save that // bails (a guest without an account, the photo cap) leaves the frame standing, // and so does an export that never produced a file. const clearAfterRef = useRef(false); const saveThenMaybeClear = useCallback( async (photoName?: string) => { const saved = await savePhotoToFolder(photoName); // The name prompt can stand between the button and the save, so the // intent is read where the save finishes rather than where it started. if (saved && clearAfterRef.current) { clearAfterRef.current = false; clearPhoto(); } return saved; }, [savePhotoToFolder, clearPhoto] ); const exportThenClear = useCallback(async () => { // The CLEAR dialog's EXPORT hands over the file as it already is, no menu. if (await exportPhoto(null)) clearPhoto(); }, [exportPhoto, clearPhoto]); // EXPORT asks for a size first. The gate is checked here as well as in // exportPhoto so a guest never sees a menu it cannot use. const openExportPicker = useCallback(() => { if (!engine || !source) return; if (!pro && proLookInUse()) { promptPro(); return; } // Some menu entry will be larger than this photo (the crop already counted), // so an upscale is one tap away: fetch the runtime and the model while the // visitor is still reading the sizes, instead of after they have chosen one. if (editedLongest < (TIERS[pro ? 'user' : 'guest'].maxExportDim ?? 4096)) preloadSuperRes(); setChoosingExport(true); }, [engine, source, pro, proLookInUse, promptPro, editedLongest]); // The original file, handed back byte for byte — the untouched frame the // camera took, never the render. A page cannot write to the camera roll, so // on a phone the file goes to the share sheet and "Save Image" on it is the // tap that files it in the photo library; a browser with no file sharing (or // a desktop) downloads it instead. const saveOriginal = useCallback(async () => { if (!source) return; const ext = source.name.split('.').pop()?.toLowerCase() ?? ''; const type = (ORIGINAL_MIME as Record)[ext] ?? 'image/jpeg'; const file = new File([source.bytes as BlobPart], source.name, { type }); try { if (navigator.canShare?.({ files: [file] })) { await navigator.share({ files: [file] }); return; } } catch (err) { // The sheet was closed: a "not now", not a failure, and no download on // top of the choice the user just made. if (err instanceof DOMException && err.name === 'AbortError') return; } download(source.bytes, source.name, type); }, [source]); // Reopen one of this account's saved frames: the bytes come back as a File // through the same door an upload uses, then the stored look goes on top. const openMyPhoto = useCallback( async (photo: Photo) => { setBusy(t('auth.busy')); setError(null); // The stage is emptied before the fetch, not after it: the frame being // reopened must not land on top of the one already standing there. clearPreview(); setHistPick(null); try { // The base is the pixels the look was applied to; `/file` is only the // fallback, for a photo saved before its base was kept. Loading the // render is what applied the look a second time — the doubled frame and // the stacked grade. const base = await fetch(api.photoBaseUrl(photo.id)); const res = base.ok ? base : await fetch(api.photoUrl(photo.id)); if (!res.ok) throw new Error(`HTTP ${res.status}`); const blob = await res.blob(); await loadFile(new File([blob], `${photo.id}.jpg`, { type: blob.type || 'image/jpeg' })); const saved = photo.recipe as (Recipe & { frameId?: FrameId; useGeotag?: boolean }) | null; if (saved && base.ok) { applyRecipe(saved); if (saved.frameId) setFrameId(saved.frameId); setUseGeotag(!!saved.useGeotag); } else { // No base: those pixels already carry the look, so it stands as it // is — the stored one is not laid over it again. The neutral stock // leaves them untouched, and the stage starts from nothing. restoreLook({ ...defaultLook(), recipe: BASE_RECIPE, simId: null, frameId: 'none' }); } setOpenPhotoId(photo.id); } catch { setError(t('err.decode')); } finally { setBusy(null); } }, [loadFile, applyRecipe, restoreLook, defaultLook, clearPreview, t] ); // The saved frame's own switch. The reel reads `consent`, so the tick answers // the click at once; only the store's refusal (a failed call) takes it back. const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => { if (!photo) return; const next = photo.consent === false; const mark = (value: boolean) => setMyPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, consent: value } : p))); mark(next); try { await api.setPhotoConsent(photo.id, next); } catch { mark(!next); } }, []); // Removing a frame takes its row and the looks its history kept. Confirmed // first (ui/DeletePhotoModal), never on a single click. const removeMyPhoto = useCallback( async (photo: Photo) => { try { await api.deletePhoto(photo.id); setMyPhotos((prev) => prev.filter((p) => p.id !== photo.id)); if (openPhotoId === photo.id) setOpenPhotoId(null); } catch (err) { setError(t('err.generic', { msg: err instanceof Error ? err.message : String(err) })); } finally { setRemoving(null); } }, [openPhotoId, t] ); // --- strips --------------------------------------------------------------- // The cascade: a chip opens the next column, and the column it came from stays // put (TEMP -> COLOR TEMP leaves TEMP visible with the ruler beside it). The // recipes list is the one exception — it belongs to PRESETS, not to a chip, so // anything else opening closes it. const toggleParam = useCallback( (key: string) => { const close = openParam === key; setOpenParam(close ? null : key); setShowRecipes(false); // Opening a ruler clears a strip from another branch; closing one leaves // its own strip standing, so the column the ruler came from is still there. if (!close) setOpenGroup(PARAM_GROUP[key] ?? null); }, [openParam] ); const toggleGroup = useCallback( (g: GroupKey) => { const next = openGroup === g ? null : g; if (next) { // A ruler only survives if it belongs to the strip being opened. if (PARAM_GROUP[openParam ?? ''] !== next) setOpenParam(null); setShowRecipes(false); } setOpenGroup(next); }, [openGroup, openParam] ); useEffect(() => { setOpenParam(null); setOpenGroup(null); setShowRecipes(false); // Leaving the mixer disarms the eyedropper and takes its panel off the // photo: both are modes of that tab. setPicking(false); setPickedAt(null); }, [tab]); // STRAIGHTEN's scale is a mode of the photo, like the eyedropper: any click // that lands outside it applies the angle and takes the scale off the photo — // so reaching for another tab, chip or button levels the photo and puts the // ruler away in the same gesture. Nothing is pending: the angle is already // live in the render, this only ends the session. useEffect(() => { if (openParam !== 'straighten') return; const dismiss = (e: PointerEvent) => { const el = e.target as HTMLElement | null; if (el?.closest('[data-key="straighten-ruler"], .chip[data-key="straighten"]')) return; setOpenParam(null); }; window.addEventListener('pointerdown', dismiss, true); return () => window.removeEventListener('pointerdown', dismiss, true); }, [openParam]); // Continuous parameter chips: label + live value; amber text when edited, // amber border while its ruler is open. const paramChips = (defs: ContinuousParamDef[]): ChipDef[] => defs.map((def) => { const value = def.get(recipe.adjustments); const locked = !!def.pro && !pro; return { key: def.key, label: def.label, value: def.display(value), active: openParam === def.key, amberValue: openParam !== def.key && value !== def.defaultValue, // The phone greys a PRO knob out; the web keeps it clickable and says // why instead, because the fix here is an action (prove the address) // rather than a purchase. pro: !!def.pro, onClick: locked ? promptPro : () => toggleParam(def.key), }; }); const slider: SliderDef | null = useMemo(() => { if (!openParam) return null; // STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not // stacked in this column at all. Its ruler lies across the foot of the // photo (StraightenRuler, drawn by ImageStage) so the angle is read off the // picture it is levelling; nothing opens here. TONE CURVE is the same shape // of tool: its graph is on the photo, so it opens no row either. if (openParam === 'straighten' || openParam === 'curve') return null; // The mixer's three knobs are not in PARAM_DEFS: they read and write the // image's own move rather than a flat field of adjustments, and unlike the // panel on the photo they are not tied to the band the mixer is aimed at. if (openParam.startsWith('hsl.')) { const which = openParam === 'hsl.h' ? 0 : openParam === 'hsl.s' ? 1 : 2; const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0]; const value = own[which]; const set = (v: number) => setHslGlobal(which as 0 | 1 | 2, v); const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM'; return { key: openParam, label: `${name} IMAGE`, value, min: -10, max: 10, step: 1, display: value > 0 ? `+${value}` : String(value), edited: value !== 0, onChange: set, onReset: () => set(0), onBack: () => setOpenParam(null), }; } const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters]; const def = all.find((d) => d.key === openParam); if (!def) return null; const value = def.get(recipe.adjustments); return { key: def.key, label: def.label, value, min: def.min, max: def.max, step: def.step, display: def.display(value), edited: value !== def.defaultValue, swatch: SLIDER_SWATCH[def.key]?.(value), onChange: (value) => setAdjustmentOnce(def.set(value)), onReset: () => setAdjustmentOnce(def.set(def.defaultValue)), // Back reopens the strip the row was launched from (COLOR TEMP -> TEMP). onBack: () => { const g = PARAM_GROUP[def.key]; setOpenParam(null); if (g) setOpenGroup(g); }, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [openParam, recipe.adjustments, setAdjustmentOnce, setHslGlobal, straighten, setStraightenOnce]); // The eyedropper's report: print what was read, point the ruler at that // colour's band, hang the mixer's panel on the spot it came from, and put the // tool away — one pick is one colour. const pickColor = useCallback((rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => { setSample(rgb); setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h)); setPickedAt(at); setPicking(false); }, []); const picked = sample ? rgbToHsl(sample.r, sample.g, sample.b) : null; const bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? ''; // The colour as this band's knobs leave it: the panel's swatch, and what the // mixer is doing to every pixel the band owns. const mixed = (() => { if (!picked) return null; const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); const h = picked.h + band[0]; const s = Math.max(0, Math.min(100, picked.s * 100 + band[1])); const l = Math.max(0, Math.min(100, picked.l * 100 + band[2])); return { hex: hslToHex(h, s, l) }; })(); const bandKnobs = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand); // The panel the stage hangs on the picked point: the colour on top (it moves // with the knobs), then HUE/SAT/LUM in one row under it. Each takes the wheel // for one step and a double-click back to 0. const pickPanel = sample && picked && mixed && pickedAt ? ( <>
{bandName} {mixed.hex}
setBandKnob(0, v)} onReset={() => setBandKnob(0, 0)} /> setBandKnob(1, v)} onReset={() => setBandKnob(1, 0)} /> setBandKnob(2, v)} onReset={() => setBandKnob(2, 0)} />
) : null; // Option strips: a chip opens one, each option picks a value. `off` is the // neutral value the chip glows amber against. // // TEMP's value: the preset the user last picked while the engine pair still // matches it, otherwise the preset that pair maps to (neutral ⇒ AUTO), // otherwise the bare kelvin — where COLOR TEMP dragged by hand lands. The // phone's own wbValue(), so one pair lights one chip on both platforms. const wbValue = (): string => { const t = Math.round(recipe.adjustments.temperature); const ti = recipe.adjustments.tint ?? 0; const remembered = WB_PRESETS.find((w) => w.key === wbChoice); if (remembered && remembered.kelvin === t && remembered.tint === ti) return remembered.key; return WB_PRESETS.find((w) => w.kelvin === t && w.tint === ti)?.key ?? `${t}K`; }; // TEMP is the one strip chip that names its value, and it names it always: // TEMP AUTO at the neutral pair, TEMP SHADE on a preset, TEMP 6300K where a // hand-dragged ruler landed. const wbLabel = (): string => { const v = wbValue(); return WB_PRESETS.find((w) => w.key === v)?.label ?? v; }; // GRAIN's readout: the clump count the stock and the SIZE knob add up to, in // the print's own terms (grainPerInch, grainShader.ts). A READOUT, not a pick — // the strip prints it beside the two knobs that move it. The field swings a // fifth of it patch by patch, which is what the knob is for, not an error in // the number. const grainInch = (): string => `${grainPerInch(grainStockFor(recipe.baseFilter), recipe.adjustments.grainSize ?? 100)}/INCH`; const groupDefs: Record, { label: string; off: string; value: string; options: { v: string; d: string; pro?: boolean }[]; onPick: (v: string) => void; }> = { cx: { label: 'COLOR CHROME', off: 'none', value: recipe.adjustments.colorChrome || 'none', options: CHROME_OPTIONS, onPick: (v) => { remember(); setAdjustment({ colorChrome: v as Recipe['adjustments']['colorChrome'] }); }, }, cxb: { label: 'CHROME BLUE', off: 'none', value: recipe.adjustments.colorChromeBlue || 'none', options: CHROME_OPTIONS, onPick: (v) => { remember(); setAdjustment({ colorChromeBlue: v as Recipe['adjustments']['colorChromeBlue'] }); }, }, dr: { label: 'D.RANGE', off: 'auto', value: String(recipe.adjustments.dynamicRange ?? 'auto'), options: DR_OPTIONS, onPick: (v) => { remember(); setAdjustment({ dynamicRange: (v === 'auto' ? 'auto' : Number(v)) as Recipe['adjustments']['dynamicRange'] }); }, }, // Picking a ratio frames it on the photo (drag the corners, then APPLY) — // nothing is cut until APPLY. crop: { label: 'CROP', off: 'none', value: crop, options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c.toUpperCase() })), onPick: (v) => pickCrop(v as CropRatio), }, // RESET levels the whole frame; the quarter turns are what this strip picks, // the fine angle is the STRAIGHTEN chip below it. rotate: { label: 'ROTATE', off: '0', value: String(rotation), options: [ { v: 'reset', d: 'RESET' }, { v: '0', d: '0°' }, { v: '90', d: '90°' }, { v: '180', d: '180°' }, { v: '270', d: '270°' }, ], onPick: (v) => { remember(); setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270)); // RESET levels the whole frame, angle included. if (v === 'reset') setStraighten(0); }, }, style: { label: 'PHOTO STYLE', off: 'sim-provia', value: simId && FILM_SIMS.some((s) => s.id === simId) ? simId : '', options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name, pro: PRO_SIMS.includes(s.id) })), onPick: (v) => { const sim = FILM_SIMS.find((s) => s.id === v); if (!sim) return; // The newest three looks are the account's; the strip shows the badge // and the click says why (see the PRO gate at the top of the file). if (PRO_SIMS.includes(sim.id) && !pro) { promptPro(); return; } setShowRecipes(false); applyRecipe(filmSimToRecipe(sim)); }, }, // TEMP: the presets plus COLOR TEMP, which opens the ruler above instead // of picking a value. temp: { label: 'TEMP', off: 'auto', value: wbValue(), options: [ { v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS.map((p) => ({ v: p.key, d: p.label })), ], onPick: (v) => { if (v === 'color-temp') return toggleParam('temperature'); remember(); // A preset is the LOOK, tint included: the phone's WB-tab chip sets the // pair, and a chip that only moved the kelvin left the frame a different // colour on the two platforms. setWbChoice(v); const p = WB_PRESETS.find((w) => w.key === v) ?? WB_PRESETS[0]; setAdjustment({ temperature: p.kelvin, tint: p.tint }); }, }, // 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 on the phone; otherwise it carries the amount, // which is the one the strip is about. value: openParam === 'grain' ? 'amount' : openParam === 'grainSize' ? 'size' : String(recipe.adjustments.grain), options: [ { v: 'amount', d: 'AMOUNT' }, { v: 'size', d: 'SIZE' }, ], onPick: (v) => toggleParam(v === 'size' ? 'grainSize' : 'grain'), }, wmColor: { label: 'TEXT COLOR', off: '#ffffff', value: markStyle.color, options: WM_COLORS, onPick: (v) => { remember(); setMarkStyle((prev) => ({ ...prev, color: v })); }, }, wmSize: { label: 'TEXT SIZE', off: '1', value: String(markStyle.size), options: WM_SIZES, onPick: (v) => { remember(); setMarkStyle((prev) => ({ ...prev, size: Number(v) })); }, }, // The two FONT chips: the custom mark's own face, and GPS's (the phone // gives GPS a FONT strip too — it used to print in the bundled face only). wmFont: { label: 'TEXT FONT', off: 'DEFAULT', value: markStyle.font ?? 'DEFAULT', options: WM_FONTS, onPick: (v) => { remember(); setMarkStyle((prev) => ({ ...prev, font: v === 'DEFAULT' ? null : v })); }, }, gpsFont: { label: 'FONT', off: 'DEFAULT', value: gpsStyle.font ?? 'DEFAULT', options: WM_FONTS, onPick: (v) => { remember(); setGpsStyle((prev) => ({ ...prev, font: v === 'DEFAULT' ? null : v })); }, }, gpsColor: { label: 'COLOR', off: '#f59e0b', value: gpsStyle.color, options: WM_COLORS, onPick: (v) => { remember(); setGpsStyle((prev) => ({ ...prev, color: v })); }, }, gpsName: { label: 'PLACE NAME', off: 'ON', value: gpsStyle.showName ? 'ON' : 'OFF', options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], onPick: (v) => { remember(); setGpsStyle((prev) => ({ ...prev, showName: v === 'on' })); }, }, gpsTime: { label: 'TIME', off: 'ON', value: gpsStyle.showTime ? 'ON' : 'OFF', options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }], onPick: (v) => { remember(); setGpsStyle((prev) => ({ ...prev, showTime: v === 'on' })); }, }, }; // A strip chip names its pick on the right edge, spelled the way the strip's // own option spells it. Still on its neutral value -> nothing to name. const groupChip = (g: Exclude): ChipDef => { const { label, value, off, options } = groupDefs[g]; const picked = options.find((o) => o.v === value); return { key: `grp-${g}`, label, value: value !== off ? picked?.d ?? value : undefined, active: openGroup === g, amberValue: openGroup !== g && value !== off, onClick: () => toggleGroup(g), }; }; const choiceChips = ( prefix: string, options: { v: string; d: string; pro?: boolean }[], current: string, onPick: (v: string) => void ): ChipDef[] => options.map((o) => ({ key: `${prefix}:${o.v}`, label: o.d, active: current === o.v, pro: o.pro, onClick: () => onPick(o.v), })); const wmOn = useGeotag || markOn; // An iPhone frame shot in the in-page camera carries no EXIF, so a photo with // no GPS is no longer a dead end — the switch stays live wherever the browser // can be asked for the device's own position (see locateMe). const canLocate = typeof navigator !== 'undefined' && !!navigator.geolocation; // Each watermark type is a collapse of its own: its chip is the header, and // the header's panel (col 2) carries that mark's own controls under it — its // colour, its face, its switches, its fields. Before, the two marks shared one // column, so both sets of controls sat open at once. The mark being OFF is // what closes the section, the same rule the phone's panel draws. const gpsChips = (): ChipDef[] => [ { key: 'gps', label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF', active: useGeotag, pro: true, disabled: !gps && !(gpsManual.lat && gpsManual.lng) && !canLocate, onClick: () => { if (!pro) { promptPro(); return; } remember(); setUseGeotag(!useGeotag); }, }, ...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsFont'), groupChip('gpsName'), groupChip('gpsTime')] : []), ]; const customChips = (): ChipDef[] => [ { key: 'custom-wm', label: markOn ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF', active: markOn, onClick: () => { remember(); setMarkOn(!markOn); }, }, ...(markOn ? [groupChip('wmColor'), groupChip('wmSize'), groupChip('wmFont')] : []), ]; // Every tab carries RESET, pinned outside the scrolling chips. // RESET lights up only while the look differs from what reset() puts back — // every clause has a matching setter there, or the star never goes out. const resetDirty = useMemo(() => { const a = recipe.adjustments; const tweaked = (Object.keys(DEFAULT_ADJUSTMENTS) as (keyof typeof DEFAULT_ADJUSTMENTS)[]) .some((k) => a[k] !== DEFAULT_ADJUSTMENTS[k]); return tweaked || Object.keys(a.hslBands ?? {}).length > 0 || (simId ?? 'sim-provia') !== 'sim-provia' || frameId !== 'none' || crop !== 'none' || rotation !== 0 || straighten !== 0 || markOn || markText !== '' || markStyle.color !== DEFAULT_MARK_STYLE.color || markStyle.size !== DEFAULT_MARK_STYLE.size || markStyle.x !== DEFAULT_MARK_STYLE.x || markStyle.y !== DEFAULT_MARK_STYLE.y || markStyle.font !== DEFAULT_MARK_STYLE.font // A gradient mask is a change like any other: while one is on the photo, // RESET is the way back to the frame as it was imported. || masks.length > 0 || useGeotag || (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]); }, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]); const resetChip = (): ChipDef => ({ key: 'reset-all', label: resetDirty ? 'RESET *' : 'RESET', amberValue: resetDirty, onClick: () => { setOpenParam(null); setOpenGroup(null); setShowRecipes(false); // Under CREATE the button belongs to the form above it: it clears the // draft, which live-previews its defaults straight onto the photo. if (tab === 'create') { setCreateReset((n) => n + 1); return; } reset(); }, }); // Shown where an unverified account would otherwise just see an empty list. // The list is not empty because the account has nothing — it is shut until // the address is proven, and saying so is the whole point of the chip. const verifyChip = (): ChipDef => ({ key: 'verify-now', label: t('pro.verifyChip'), pro: true, onClick: promptPro, }); const framesRow = (): ChipDef[] => [ // NO FRAME is not a chip of its own: clicking the frame already on the // photo takes it off again, so the row carries one entry per frame. ...FRAMES.filter((frame) => frame.id !== 'none').map((frame) => ({ key: `fr:${frame.id}`, label: t(frame.label), active: frameId === frame.id, pro: PRO_FRAMES.includes(frame.id), onClick: () => { if (PRO_FRAMES.includes(frame.id) && !pro) { promptPro(); return; } setOpenParam(null); setOpenGroup(null); remember(); setFrameId(frameId === frame.id ? 'none' : frame.id); }, })), groupChip('crop'), // The chip names whichever of the two is off the straight — and both when // both are. { key: 'grp-rotate', label: 'ROTATE', value: straighten !== 0 ? `${rotation}° ${straighten > 0 ? '+' : ''}${straighten}°` : `${rotation}°`, active: openGroup === 'rotate', amberValue: openGroup !== 'rotate' && (rotation !== 0 || straighten !== 0), onClick: () => toggleGroup('rotate'), }, { key: 'grp-wm', label: wmOn ? 'WATERMARK ON' : 'WATERMARK', active: openGroup === 'wm', amberValue: openGroup !== 'wm' && wmOn, onClick: () => toggleGroup('wm'), }, ]; // The folder's frame standing on the stage, if any: its own column shows the // looks it carried before its last saves and its landing-strip consent. const openPhoto = myPhotos.find((p) => p.id === openPhotoId); // FX's MONOCHROME switch is not a look of its own: it swaps the base filter // for the mono stock and swaps right back. Adjustments are never touched, so // a knob moved while the switch is on survives the trip back. const monoOn = isMonochromeBase(recipe.baseFilter); const toggleMono = () => { remember(); if (monoOn) { const back = monoPrev.current ?? { baseFilter: 'provia' as Recipe['baseFilter'], simId: 'sim-provia', name: 'PROVIPES' }; setRecipe((prev) => ({ ...prev, baseFilter: back.baseFilter, name: back.name, isCustom: true })); setSimId(back.simId); } else { monoPrev.current = { baseFilter: recipe.baseFilter, simId, name: recipe.name }; setRecipe((prev) => ({ ...prev, baseFilter: 'monochrome', name: 'MONOCHROME', isCustom: true })); setSimId(null); } }; // The main chip row of the active tab, exactly the phone's per-tab sets. const chips: ChipDef[] = (() => { switch (tab) { case 'presets': return [ groupChip('style'), { key: 'recipes-toggle', label: 'RECIPES', active: showRecipes, onClick: () => { setOpenGroup(null); setOpenParam(null); setShowRecipes((v) => !v); }, }, // ponytail: no CREATE chip — making a recipe and filing the look on // the stage are their own rail tabs (SAVE RECENT, CREATE RECIPES). // IMPORT is a chip inside the RECIPES strip. ]; case 'favorited': if (user && !pro) return [verifyChip()]; return saved.length === 0 ? [{ key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, onClick: () => {} }] : saved.map((entry) => ({ key: String(entry.id), label: entry.name, // The look on the stage came from this entry: its name turns // accent so the open recipe is visible in the list. A same-id // preset that was merely filed under a new name must not light up, // hence the name check too. active: recipe.id === (entry.recipe as Recipe)?.id && recipe.name === entry.name, onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })); case 'photos': { // MY PHOTOS: the account's own saved frames, newest first. Opening one // reopens it for a further edit; that frame's own history, consent and // removal live in its own column (see the render). A save always joins // the landing strip — the owner takes it off again with that switch, or // the curator with the admin slot. const rows: ChipDef[] = []; if (user && !pro) return [verifyChip()]; if (myPhotos.length === 0) { rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} }); } else { rows.push( ...myPhotos.map((p) => ({ key: `photo-${p.id}`, label: p.title ?? `#${p.id}`, active: p.id === openPhotoId, onClick: () => void openMyPhoto(p), })) ); } return rows; } case 'light': return [ ...paramChips(PARAM_DEFS.iq), groupChip('dr'), // TONE CURVE is not a row of sliders: it opens the graph on the photo // (ToneCurvePanel), so the chip toggles that overlay rather than a // ruler in the last column. It glows amber once the graph is off the // diagonal, which is the only place the curve is reported. { key: 'curve', label: 'TONE CURVE', active: openParam === 'curve', amberValue: curveIsActive(recipe.adjustments.toneCurve), onClick: () => toggleParam('curve'), }, ]; case 'wb': return [ { // TEMP is the one strip chip that names its value — nothing else // does, and it names it even at the neutral pair: TEMP AUTO. ...groupChip('temp'), label: 'TEMP', value: wbLabel(), }, ...paramChips(PARAM_DEFS.wb.filter((p) => p.key !== 'temperature')), groupChip('cx'), groupChip('cxb'), ]; case 'fx': { // FX's two brushes are the tab's tools that are not a look: a chip arms // one, the wheel sizes it — the chip's own readout is that size, since // the brush has no size buttons to point at — and CLEAR hands the whole // photo back. HEAL repairs a speck of dust, MOSAIC hides a patch of the // photo from whoever looks at it; they sit above the look chips because // they change the file rather than grade it. return [ { key: 'heal', label: 'HEAL', value: brushTool === 'heal' ? `${(healR * 100).toFixed(1)}%` : undefined, active: brushTool === 'heal', amberValue: healSpots.length > 0, onClick: () => { // The tools that take the pointer on the photo never share it. setPicking(false); setMaskTool(null); setBrushTool((v) => (v === 'heal' ? null : 'heal')); }, }, ...(healSpots.length ? [{ key: 'heal-clear', label: 'CLEAR', onClick: clearHealSpots }] : []), { key: 'mosaic', label: 'MOSAIC', value: brushTool === 'mosaic' ? `${(mosaicR * 100).toFixed(1)}%` : undefined, active: brushTool === 'mosaic', amberValue: mosaicSpots.length > 0, onClick: () => { setPicking(false); setMaskTool(null); setBrushTool((v) => (v === 'mosaic' ? null : 'mosaic')); }, }, ...(mosaicSpots.length ? [{ key: 'mosaic-clear', label: 'CLEAR', onClick: clearMosaicSpots }] : []), // LINEAR and RADIAL GRADIENT are the tab's other two tools that take // the pointer: a chip arms one and the next drag on the photo lays the // shape down, inside which the column beside it grades the pixels. The // chip carries the shape's own name rather than its count, so CLEAR // below it can, and the pair sits with HEAL and MOSAIC because all // four change the photo where it is rather than the look on top of it. ...(['linear', 'radial'] as const).map((kind): ChipDef => ({ key: kind, label: kind.toUpperCase(), active: maskTool === kind, amberValue: masks.some((m) => m.kind === kind), onClick: () => { setPicking(false); setBrushTool(null); setMaskTool((v) => (v === kind ? null : kind)); }, })), ...(masks.length ? [{ key: 'masks-clear', label: 'CLEAR', onClick: clearMasks }] : []), { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, // 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. ...PARAM_DEFS.filters.flatMap((p): ChipDef[] => p.key === 'grain' ? [groupChip('grain')] : p.key === 'grainSize' ? [] : paramChips([p]) ), ]; } case 'hsl': { // The mixer is PRO: the picker, the eight bands and the three // image-wide knobs all come with the account, so a guest is offered the // reason rather than a wall of dead chips. if (!pro) return [{ key: 'hsl-pro', label: t('pro.hslChip'), pro: true, onClick: promptPro }]; // PICK arms the eyedropper; the eight band chips choose which band the // ruler edits, and the ruler itself is opened by the three knobs below. // Those three are the image's own move, not the band's: the band chips // pick which colour the panel on the photo edits, while HUE/SAT/LUM // below the divider move every hue in the frame at once. const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0]; const knob = (key: string, label: string, i: number): ChipDef => ({ key, label, value: own[i] > 0 ? `+${own[i]}` : String(own[i]), active: openParam === key, amberValue: openParam !== key && own[i] !== 0, onClick: () => toggleParam(key), }); return [ { key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => { setBrushTool(null); setMaskTool(null); setPicking((v) => !v); } }, ...HSL_BANDS.map((b): ChipDef => { const band = recipe.adjustments.hslBands?.[b.id]; const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0); return { key: `hsl-band-${b.id}`, label: b.label, // The chip names a colour, so it shows the colour it names. color: hslToHex(b.hue, 70, 50), active: hslBand === b.id, amberValue: hslBand !== b.id && moved, onClick: () => setHslBand(b.id), }; }), // The divider keeps the three image-wide sliders out of the colour // row: the row above picks a colour, these three move them all. { key: 'hsl-image', label: 'IMAGE', disabled: true, onClick: () => {} }, knob('hsl.h', 'HUE', 0), knob('hsl.s', 'SAT', 1), knob('hsl.l', 'LUM', 2), ]; } case 'frame': // WATERMARK is a chip like the rest: its own sub-panel opens as the next // column (see the wm column in the render), so the frame chips stay put. return framesRow(); // SAVE RECENT draws its own button and list, CREATE its own form; they // are not chip rows like the rest. case 'save': case 'create': return []; default: return []; } // eslint-disable-next-line react-hooks/exhaustive-deps })(); const stripChips = (g: Exclude): ChipDef[] => [ { key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} }, ...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick).map((c) => // A hand-straightened frame is no longer on a clean quarter turn: the // angle chips let go the moment the fine angle leaves 0, so the strip // shows which of the two is actually steering the photo. g === 'rotate' && straighten !== 0 && c.key !== 'rotate:reset' ? { ...c, active: false } : c ), // The frame is on the photo but the crop is not in force until APPLY. ...(g === 'crop' && cropEditing ? [ { key: 'crop-apply', label: t('crop.apply'), amberValue: true, onClick: applyCrop }, { key: 'crop-cancel', label: t('crop.cancel'), onClick: cancelCrop }, ] : []), // 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. ...(g === 'grain' ? [{ key: 'grain-inch', label: grainInch(), disabled: true, onClick: () => {} }] : []), // STRAIGHTEN is the ROTATE strip's one entry that opens a ruler instead of // picking a value: the quarter turns are choices, the angle is a knob. ...(g === 'rotate' ? [ { key: 'straighten', label: 'STRAIGHTEN', active: openParam === 'straighten', amberValue: openParam !== 'straighten' && straighten !== 0, onClick: () => toggleParam('straighten'), }, ] : []), ]; // RECIPES = this account's own files (SAVE RECENT) + what this session made // but never filed + the bundled presets. A CREATE that a signed-in account // stored lands in `saved` too, so it is dropped from `created` by name here. // IMPORT takes the .recipe file the phone (or another account) exports — same // envelope, same parser, so a look crosses devices intact. const recipeStrip: ChipDef[] = [ { key: 'recipes-import', label: 'IMPORT .RECIPE', onClick: pickRecipeFile }, ...saved.map((entry) => ({ key: `saved-${entry.id}`, label: entry.name, active: recipe.name === entry.name, onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }), })), ...created .filter((entry) => !saved.some((s) => s.name.trim().toLowerCase() === entry.name.trim().toLowerCase())) .map((entry) => ({ key: entry.id, label: entry.name, active: recipe.id === entry.id, drag: entry.id, onClick: () => applyRecipe(entry), })), ...DEFAULT_RECIPES.map((preset) => ({ key: preset.id, label: preset.name, active: recipe.name === preset.name && !recipe.isCustom, drag: preset.id, onClick: () => applyRecipe({ ...preset }), })), ]; // The panel is a cascade of columns (see styles/app.css): the tab's chips, the // open chip's own panel, the open group's options, the open ruler. Each level // is a column of its own, so a child never hides the column it came from. const wmOpen = isWmPanel(openGroup); const optGroup = openGroup && openGroup !== 'wm' ? openGroup : null; // The lines GPS prints, spelled exactly as the engine spells them, so the box // that frames them is the box the file gets. const gpsLines = useMemo(() => { if (!gpsInfo) return []; const out: string[] = []; if (gpsStyle.showName) { out.push( gpsInfo.locality?.trim() || `${gpsInfo.latitude.toFixed(5)}, ${gpsInfo.longitude.toFixed(5)}` ); } if (gpsStyle.showTime) { const d = new Date(gpsInfo.timestamp); const pad = (n: number) => String(n).padStart(2, '0'); out.push( `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}` ); } return out; }, [gpsInfo, gpsStyle.showName, gpsStyle.showTime]); const status = busy; return (
0} canRedo={redo.length > 0} onUndo={undoLast} onRedo={redoLast} onReset={reset} onSavePhoto={() => void saveThenMaybeClear()} onExport={openExportPicker} photoCount={myPhotos.length} photoMax={photoCap} onAuth={() => setAuthMode('login')} onSignup={() => setAuthMode('signup')} onLogout={async () => { await api.logout().catch(() => undefined); await clearSession(); // the stage still holds the old photo in memory; a reload is the one // line that empties it and every knob with it. window.location.reload(); }} /> {/* A signed-in account whose address is unproven is served as a guest, so the studio says so once, at the top, instead of letting each locked chip explain it on its own. */} {user && !pro ? (
{t('pro.unverifiedBar', { email: user.email })}
) : null}
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
{tab === 'create' ? ( void addCreatedRecipe(draft)} onExport={exportRecipeFile} /> ) : tab === 'save' ? ( <> {/* SAVE RECENT files the look standing on the stage — sim, WB, light, FX and frame — as a recipe of this account's own. */}

{t('save.recentHint')}

{saved.length === 0 ? (

{t('save.recentEmpty')}

) : (
{saved.map((entry) => (
{/* Only this account can delete its own file — the API scopes the delete by user, the button mirrors it. */}
))}
)} ) : ( )} {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} {/* The mixer's readout: the colour the eyedropper last read, as the RGB bytes and the HSL the band is indexed by. */} {tab === 'hsl' && pro ? (
{sample && picked ? ( <> R {sample.r} · G {sample.g} · B {sample.b} {Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}% {HSL_BANDS.find((b) => b.id === hslBand)?.label} ) : (

{t('hsl.hint')}

)}
) : null} {/* RESET is the way out of an edit, not one more chip: it sits under a rule at the foot of the column so it reads as its own thing. */}
{/* column 2 — FX's GRADIENT MASK, while one is chosen: the shape's own name, the way to take it off the photo, and the three knobs it grades with. A radial mask adds the feather it fades over, since that is the number its rim is made of. */} {selMask ? (
{} }, { key: 'mask-delete', label: 'DELETE', onClick: deleteMask }, ]} />
`${v > 0 ? '+' : ''}${v.toFixed(1)}`} onChange={(v) => setMaskKnob({ exposure: v })} onReset={() => setMaskKnob({ exposure: 0 })} /> setMaskKnob({ contrast: Math.round(v) })} onReset={() => setMaskKnob({ contrast: 0 })} /> setMaskKnob({ saturation: Math.round(v) })} onReset={() => setMaskKnob({ saturation: 0 })} /> {selMask.kind === 'radial' ? ( `${v}%`} onChange={(v) => setMaskKnob({ feather: v / 100 })} onReset={() => setMaskKnob({ feather: MASK_DEFAULT_FEATHER })} /> ) : null}
) : null} {/* column 2b — FRAME's WATERMARK chip opens the two collapses, one per watermark type: the header chip, then that mark's own controls */} {wmOpen ? (
setOpenGroup(null) }]} /> {/* GPS WATERMARK — its colour, its face, its two rows, its place */}
{useGeotag ? ( <> setPlace(e.target.value)} />

{gps ? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual') : t('wm.noExif')}

{!gps ? ( <>
setGpsManual((prev) => ({ ...prev, lat: e.target.value }))} onBlur={() => { const lat = Number(gpsManual.lat); const lng = Number(gpsManual.lng); if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) { setGps({ latitude: lat, longitude: lng, locality: place, timestamp: capturedAt ?? Date.now() }); setUseGeotag(true); } }} /> setGpsManual((prev) => ({ ...prev, lng: e.target.value }))} />
) : null} ) : null}
{/* CUSTOM WATERMARK — its text, its colour, its size, its face */}
{markOn ? ( setMarkText(e.target.value)} /> ) : null}
) : null} {/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */} {optGroup ? (
{/* A watermark's colour: the presets above are a shortcut, the well under them picks any colour at all. */} {optGroup === 'wmColor' || optGroup === 'gpsColor' ? ( ) : null}
) : null} {tab === 'presets' && showRecipes ? (
{recipeStrip.length === 1 ?

{t('sec.savedEmpty')}

: null}
) : null} {/* MY PHOTOS' own column, for the frame standing on the stage: the looks it carried before its last saves (newest first, three kept) and the switch that puts it on the landing film strip. */} {tab === 'photos' && openPhoto ? (
{openPhoto.history?.length ? ( {} }, ...openPhoto.history.map((look, i) => ({ key: `hist-${i}`, label: look.name || `#${i + 1}`, // The picked look is marked like the open recipe in the // RECIPES list — accent, so it follows the theme. active: histPick === i, onClick: () => { // An older look is loaded the same way a frame is: the // preview goes first, then the look it names is painted. clearPreview(); applyRecipe({ ...look }); setHistPick(i); }, })), ]} /> ) : (

{t('myp.historyEmpty')}

)} {/* The frame's own removal. The row goes, and with it the looks its history kept, so the dialog asks before it happens. */}
) : null} {/* column 4 — the open ruler, sliders stacked */} {slider ? (
) : null}
void loadFile(file)} cropEditing={cropEditing} cropRect={cropRect} cropWH={cropWH} dims={stageDims} onCropRectChange={setCropRect} onStageZoom={onStageZoom} canCompare={!!source} onSaveOriginal={() => void saveOriginal()} onClear={() => setClearing(true)} picking={picking} onPickColor={pickColor} brushTool={brushTool} brushSpots={brushSpots} brushR={brushR} onBrushR={setBrushR} onBrushSpots={addBrushSpots} onBrushEdit={editBrushSpots} maskTool={maskTool} masks={masks} maskSel={maskSel} onMaskSel={setMaskSel} onMaskCreate={addMask} onMaskEdit={editMasks} pickPanel={pickPanel} pickPanelAt={pickedAt} // FRAME's custom mark owns a box on the photo while its panel is // open: the engine draws the text at (x, y) with size × the base // face, and the box is those same three numbers read back off the // render. Only while the panel shows — a box with nothing to edit it // would just sit on the photo. marks={ wmOpen ? [ ...(markOn && markText ? [ { key: 'custom', x: markStyle.x, y: markStyle.y, size: markStyle.size, baselineEm: CUSTOM_BASELINE_EM, gapEm: 0, lines: [markText], font: markStyle.font, onMove: setWatermarkPos, }, ] : []), ...(useGeotag && gps && gpsLines.length ? [ { key: 'gps', x: gpsStyle.x, y: gpsStyle.y, size: gpsStyle.size, baselineEm: GPS_BASELINE_EM, gapEm: GPS_GAP_EM, lines: gpsLines, font: gpsStyle.font, onMove: setGpsPos, }, ] : []), ] : [] } straightenRuler={ openParam === 'straighten' ? ( setStraightenOnce(0)} /> ) : null } curvePanel={ openParam === 'curve' ? ( setAdjustmentOnce({ toneCurve: Object.keys(next).length ? next : undefined }) } onClose={() => setOpenParam(null)} /> ) : null } />
{naming === 'recipe' ? ( { setNaming(null); void saveRecipeNamed(name); }} onClose={() => setNaming(null)} /> ) : null} {naming === 'photo' ? ( { setNaming(null); void saveThenMaybeClear(name); }} onClose={() => setNaming(null)} /> ) : null} {clearing ? ( { setClearing(false); // The save may still have to ask for a name; the flag makes that // save the one that takes the photo off the stage. clearAfterRef.current = true; void saveThenMaybeClear(); }} onExport={() => { setClearing(false); void exportThenClear(); }} onDiscard={() => { setClearing(false); clearPhoto(); }} onClose={() => setClearing(false)} /> ) : null} {choosingExport ? ( { setChoosingExport(false); void exportPhoto(target); }} onClose={() => setChoosingExport(false)} /> ) : null} {removing ? ( void removeMyPhoto(removing)} onClose={() => setRemoving(null)} /> ) : null} {authMode ? ( setAuthMode(null)} onDone={async () => { setAuthMode(null); const me = await api.me().catch(() => null); if (me) setUser(me.user); }} /> ) : null}
); }