Files
RecipesCam/docker/frontend/src/App.tsx
T
3dtours b568fa3fdc web: let FX carry Lightroom's two gradient masks, and grade inside them
FX had two tools that change the photo where it is — HEAL repairs a speck,
MOSAIC hides a patch — and every knob that graded the frame graded all of it.
The scratchpad's gradient_mask.md asks for the two local adjustments the phone's
own editor has and Lightroom made familiar: a linear gradient and a radial one.
This is that spec, written for the renderer this app actually has.

A mask is a SHAPE rather than a value, so it is dragged rather than turned: the
LINEAR chip arms a ramp and the next drag on the photo is its two ends — zero at
the press, one at the release, the spec's own convention, which is what makes the
same gesture a wide fade or a hard edge — and RADIAL arms an ellipse whose centre
is the press, whose semi-axes are the drag's own distance and whose axis lies
along the direction the hand went, so the circle a drag describes is the circle
the mask starts life as. Both shapes keep a pin (the whole shape travels by it)
and, while chosen, the handles that move the ends or the axes and the one that
turns the ellipse; what is drawn is the shape the render will read, so the ramp
and the rim are visible before a knob is moved.

Inside the shape, three knobs grade in the spec's own order and its own maths:
exposure as `pow(2.0, e)` in stops (its -5..+5), contrast about the middle,
saturation as a mix away from the pixel's own REC-709 luma — the mixer's
-10..+10 read as the spec's -1..+1 — and a radial mask adds the feather it fades
over, which is the fraction of its own axis the alpha holds full before it dies
at the rim. Several masks run in the order they were drawn, each reading what the
one before it left, which is what a stack of local adjustments is.

The maths is GLSL in the md and the renderer is Skia (canvaskit-wasm, SkSL
runtime effects), so it is ported stage for stage: one pass, after the frame-wide
grade and the vignette and before HEAL, because a local adjustment is part of the
look and not a repair — the pixels a repair borrows are then meant to carry the
mask's light already. Preview and export both come through renderPhoto, so the
file carries the masks the stage is showing by construction, and the shape and
the knobs ride in the recipe's own JSON, which is what makes them survive a save.

The chips sit with HEAL and MOSAIC because all four take the pointer on the
photo, and they are exclusive with every other armed tool, the eyedropper
included — while a mask tool is armed the layer takes the photo, so a drag means
"draw the next shape" and a press on a pin means "take hold of this one", which
is why the shapes already laid are answered through their pin and handles alone.
A knob drag on a mask is one undo step, a shape drag is one more, a press that
only chose a mask records nothing at all, and RESET is the way back with the
whole frame as it was imported.

ponytail: the spec's own "Gợi ý nâng cấp" rung — Highlights and Shadows isolated
with pow(luma, 3) and pow(1-luma, 3) weight masks — is not here, and neither is
Lightroom's per-mask invert and colour/tone range. The three knobs are what
"gradient mask" means until a photo shows a sky that has to be rescued apart from
the grass under it; the md itself calls it an upgrade, not the feature.

Verified: tsc clean; mask-probe 35/0 on the dev server and again on 8090 (the two
chips, both shapes drawn and moved and turned, the ramp read off the pixels —
61 -> 244 at the release and 61 at the press — the feather read off the rings,
DELETE/UNDO/REDO/CLEAR, and one gesture one undo step); brush-edit 33/0,
heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/award-column/otp-code/
tone-curve all ALL PASS, backend 180/0.
2026-09-24 10:42:58 +07:00

3180 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Record<string, (v: number) => 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<Record<string, GroupKey>> = {
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<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [source, setSource] = useState<LoadedSource | null>(null);
const [previewBytes, setPreviewBytes] = useState<Uint8Array | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(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<string | null>(null);
const [recipe, setRecipe] = useState<Recipe>(() => ({
...DEFAULT_RECIPES[0],
id: 'web-current',
name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA',
...session.recipe,
}));
const [simId, setSimId] = useState<string | null>(() => session.simId ?? null);
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
const [cropRect, setCropRect] = useState<CropRect>(() => 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<string | null>(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<HslBandId>('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<number | null>(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<GroupKey | null>(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<string | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [tab, setTab] = useState<TabId>('presets');
const [peek, setPeek] = useState(false);
const [gps, setGps] = useState<GPSInfo | null>(() => session.gps ?? null);
// ISO off the loaded photo's EXIF; null means the strip prints AUTO.
const [iso, setIso] = useState<number | null>(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<number | null>(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<GpsStyle>(() => ({ ...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<MarkStyle>(() => ({ ...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<Look[]>([]);
const [redo, setRedo] = useState<Look[]>([]);
// 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<number | null>(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<Look | null>(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<User | null>(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<SavedRecipe[]>([]);
// 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<Photo[]>([]);
// The saved photo standing on the stage, if any — its own consent chip shows.
const [openPhotoId, setOpenPhotoId] = useState<number | null>(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<Recipe[]>([]);
// 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<Photo | null>(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<Recipe['adjustments']>) => {
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<Recipe['adjustments']>) => {
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<GradientMask>) => {
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<Pick<MarkStyle, 'x' | 'y' | 'size'>>) => {
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<Pick<GpsStyle, 'x' | 'y' | 'size'>>) => {
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<Recipe, 'baseFilter' | 'adjustments'>) => {
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 <input type=file> 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<boolean> => {
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<boolean> => {
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<string, string>)[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 ? (
<>
<div className="hsl-card-head">
<span
className="hsl-card-swatch"
data-key="hsl-panel-swatch"
data-color={mixed.hex}
style={{ background: mixed.hex }}
/>
<span className="hsl-card-name" data-key="hsl-panel-name">
{bandName}
<b>{mixed.hex}</b>
</span>
<button
type="button"
className="hsl-card-close"
data-key="hsl-panel-close"
aria-label="close"
onClick={() => setPickedAt(null)}
>
✕
</button>
</div>
<div className="hsl-card-knobs">
<MiniSlider label="HUE" value={bandKnobs[0]} onChange={(v) => setBandKnob(0, v)} onReset={() => setBandKnob(0, 0)} />
<MiniSlider label="SAT" value={bandKnobs[1]} onChange={(v) => setBandKnob(1, v)} onReset={() => setBandKnob(1, 0)} />
<MiniSlider label="LUM" value={bandKnobs[2]} onChange={(v) => setBandKnob(2, v)} onReset={() => setBandKnob(2, 0)} />
</div>
</>
) : 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<Exclude<GroupKey, 'wm'>, {
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<GroupKey, 'wm'>): 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<GroupKey, 'wm'>): 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 (
<div className="shell">
<TopBar
recipeName={recipe.name}
pro={pro}
user={user}
exporting={exporting}
savingPhoto={savingPhoto}
canUndo={undo.length > 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 ? (
<div className="verify-bar" role="status">
<span>{t('pro.unverifiedBar', { email: user.email })}</span>
<button
type="button"
className="btn"
data-key="verify-resend"
disabled={resent}
onClick={() => void resendVerification()}
>
{resent ? t('pro.resent') : t('pro.resend')}
</button>
</div>
) : null}
<div className="workspace">
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} signedIn={!!user} />
<div className="chips">
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
<div className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}`} data-col="main">
{tab === 'create' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={pro}
resetToken={createReset}
onPreview={previewCreatedRecipe}
onSave={(draft) => 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. */}
<button
type="button"
className="btn primary"
data-key="save-recent"
onClick={() => void saveRecipe()}
>
{t('save.recent')}
</button>
<p className="hint">{t('save.recentHint')}</p>
{saved.length === 0 ? (
<p className="hint">{t('save.recentEmpty')}</p>
) : (
<div className="saved-list">
{saved.map((entry) => (
<div className="saved-row" key={entry.id}>
<button
type="button"
className="chip"
data-key={`saved-${entry.id}`}
aria-pressed={recipe.name === entry.name}
onClick={() => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name })}
>
{entry.name}
</button>
{/* Only this account can delete its own file — the API
scopes the delete by user, the button mirrors it. */}
<button
type="button"
className="chip"
data-key={`saved-del-${entry.id}`}
title={t('save.delete')}
aria-label={t('save.delete')}
onClick={() => void deleteRecipeEntry(entry)}
>
✕
</button>
</div>
))}
</div>
)}
</>
) : (
<ChipRow chips={chips} />
)}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : 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 ? (
<div className="hsl-readout" data-key="hsl-readout">
{sample && picked ? (
<>
<span
className="hsl-swatch"
data-key="hsl-swatch"
data-color={`rgb(${sample.r}, ${sample.g}, ${sample.b})`}
style={{ background: `rgb(${sample.r}, ${sample.g}, ${sample.b})` }}
/>
<span className="hsl-line" data-key="hsl-rgb">
R {sample.r} · G {sample.g} · B {sample.b}
</span>
<span className="hsl-line" data-key="hsl-hsl">
{Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}%
</span>
<span className="hsl-line" data-key="hsl-band">
{HSL_BANDS.find((b) => b.id === hslBand)?.label}
</span>
</>
) : (
<p className="hint">{t('hsl.hint')}</p>
)}
</div>
) : 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. */}
<div className="reset-row">
<ChipRow chips={[resetChip()]} />
</div>
</div>
{/* 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 ? (
<div className="col col-sub strip" data-col="mask">
<ChipRow
chips={[
{ key: 'mask-kind', label: selMask.kind.toUpperCase(), active: true, onClick: () => {} },
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
]}
/>
<div className="hsl-card-knobs">
<MiniSlider
label="EXP"
value={selMask.exposure}
min={-MASK_EXPOSURE_MAX}
max={MASK_EXPOSURE_MAX}
step={0.1}
prefix="mask-knob"
format={(v) => `${v > 0 ? '+' : ''}${v.toFixed(1)}`}
onChange={(v) => setMaskKnob({ exposure: v })}
onReset={() => setMaskKnob({ exposure: 0 })}
/>
<MiniSlider
label="CON"
value={selMask.contrast}
prefix="mask-knob"
onChange={(v) => setMaskKnob({ contrast: Math.round(v) })}
onReset={() => setMaskKnob({ contrast: 0 })}
/>
<MiniSlider
label="SAT"
value={selMask.saturation}
prefix="mask-knob"
onChange={(v) => setMaskKnob({ saturation: Math.round(v) })}
onReset={() => setMaskKnob({ saturation: 0 })}
/>
{selMask.kind === 'radial' ? (
<MiniSlider
label="FEA"
value={Math.round(selMask.feather * 100)}
min={0}
max={100}
step={5}
prefix="mask-knob"
format={(v) => `${v}%`}
onChange={(v) => setMaskKnob({ feather: v / 100 })}
onReset={() => setMaskKnob({ feather: MASK_DEFAULT_FEATHER })}
/>
) : null}
</div>
</div>
) : 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 ? (
<div className="col col-sub strip wm-extra" data-col="wm">
<ChipRow chips={[{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }]} />
{/* GPS WATERMARK — its colour, its face, its two rows, its place */}
<div className="wm-sec" data-key="wm-sec-gps">
<ChipRow chips={gpsChips()} />
{useGeotag ? (
<>
<input
className="field"
value={place}
placeholder={t('wm.placePh')}
onChange={(e) => setPlace(e.target.value)}
/>
<p className="hint">
{gps
? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual')
: t('wm.noExif')}
</p>
{!gps ? (
<>
<div className="chip-row">
<button
type="button"
className="chip"
data-key="gps-locate"
disabled={locating}
onClick={() => locateMe()}
>
{locating ? t('wm.locating') : t('wm.useLocation')}
</button>
</div>
<div className="chip-row">
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lat')}
value={gpsManual.lat}
onChange={(e) => 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);
}
}}
/>
<input
className="field"
style={{ width: '48%' }}
placeholder={t('wm.lng')}
value={gpsManual.lng}
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
/>
</div>
</>
) : null}
</>
) : null}
</div>
{/* CUSTOM WATERMARK — its text, its colour, its size, its face */}
<div className="wm-sec" data-key="wm-sec-custom">
<ChipRow chips={customChips()} />
{markOn ? (
<input
className="field"
value={markText}
placeholder="TYPE WATERMARK TEXT"
maxLength={80}
onChange={(e) => setMarkText(e.target.value)}
/>
) : null}
</div>
</div>
) : null}
{/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */}
{optGroup ? (
<div className="col col-sub strip" data-col="options">
<ChipRow chips={stripChips(optGroup)} />
{/* A watermark's colour: the presets above are a shortcut, the
well under them picks any colour at all. */}
{optGroup === 'wmColor' || optGroup === 'gpsColor' ? (
<label className="color-well" data-key={`wm-color-${optGroup}`}>
<input
type="color"
data-key={`wm-color-input-${optGroup}`}
value={optGroup === 'wmColor' ? markStyle.color : gpsStyle.color}
onChange={(e) => setWmColor(e.target.value)}
/>
<span className="val">{optGroup === 'wmColor' ? markStyle.color : gpsStyle.color}</span>
</label>
) : null}
</div>
) : null}
{tab === 'presets' && showRecipes ? (
<div className="col col-sub strip" data-col="recipes">
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
<ChipRow chips={recipeStrip} />
</div>
) : 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 ? (
<div className="col col-sub strip" data-col="history">
{openPhoto.history?.length ? (
<ChipRow
chips={[
{ key: 'hist-title', label: t('myp.history'), disabled: true, onClick: () => {} },
...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);
},
})),
]}
/>
) : (
<p className="hint">{t('myp.historyEmpty')}</p>
)}
<label className="consent">
<input
type="checkbox"
data-key="strip-open"
checked={openPhoto.consent !== false}
onChange={() => void togglePhotoConsent(openPhoto)}
/>
<span>{t('myp.consent')}</span>
</label>
{/* The frame's own removal. The row goes, and with it the looks
its history kept, so the dialog asks before it happens. */}
<button
type="button"
className="btn adm-danger"
data-key="photo-remove"
onClick={() => setRemoving(openPhoto)}
>
{t('myp.remove')}
</button>
</div>
) : null}
{/* column 4 — the open ruler, sliders stacked */}
{slider ? (
<div className="col col-slider" data-col="slider">
<SliderRow slider={slider} />
</div>
) : null}
</div>
<ImageStage
previewUrl={previewUrl}
// Nothing to show until the neutral render lands: the file itself is
// the wrong geometry the moment the photo has been turned or cropped.
originalUrl={comparing ? compareUrl : source?.url ?? null}
compare={comparing}
onCompareChange={setComparing}
busy={status}
error={error}
peek={peek}
onPeek={setPeek}
onFile={(file) => 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' ? (
<StraightenRuler value={straighten} onChange={setStraightenOnce} onReset={() => setStraightenOnce(0)} />
) : null
}
curvePanel={
openParam === 'curve' ? (
<ToneCurvePanel
curve={recipe.adjustments.toneCurve}
// The distribution behind the graph is the render's own, so it
// is the same picture the curve is being judged against.
url={previewUrl}
// One undo step per visit to the graph, the same rule the ruler
// and the watermark box ride: a drag is one edit, not one per
// pointer move. RESET hands back an empty object, and that has
// to leave the recipe without a curve at all rather than with a
// `toneCurve: {}` — the field is what "there is a curve" means.
onChange={(next) =>
setAdjustmentOnce({ toneCurve: Object.keys(next).length ? next : undefined })
}
onClose={() => setOpenParam(null)}
/>
) : null
}
/>
</div>
{naming === 'recipe' ? (
<NameModal
title={t('name.recipeTitle')}
label={t('name.recipeLabel')}
initial={recipe.name}
check={recipeNameTaken}
onSubmit={(name) => {
setNaming(null);
void saveRecipeNamed(name);
}}
onClose={() => setNaming(null)}
/>
) : null}
{naming === 'photo' ? (
<NameModal
title={t('name.photoTitle')}
label={t('name.photoLabel')}
initial={strip.title}
onSubmit={(name) => {
setNaming(null);
void saveThenMaybeClear(name);
}}
onClose={() => setNaming(null)}
/>
) : null}
{clearing ? (
<ClearPhotoModal
onSave={() => {
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 ? (
<ExportModal
longest={editedLongest}
maxDim={TIERS[pro ? 'user' : 'guest'].maxExportDim}
onPick={(target) => {
setChoosingExport(false);
void exportPhoto(target);
}}
onClose={() => setChoosingExport(false)}
/>
) : null}
{removing ? (
<DeletePhotoModal
id={removing.id}
name={removing.title ?? `#${removing.id}`}
historyCount={removing.history?.length ?? 0}
onConfirm={() => void removeMyPhoto(removing)}
onClose={() => setRemoving(null)}
/>
) : null}
{authMode ? (
<AuthModal
initialMode={authMode}
email={user?.email}
onClose={() => setAuthMode(null)}
onDone={async () => {
setAuthMode(null);
const me = await api.me().catch(() => null);
if (me) setUser(me.user);
}}
/>
) : null}
</div>
);
}