7b6a1a805f
The strip a chip opened kept a row of its own while a knob's ruler opened over it, so WB's COLOR TEMP put the panel on the screen twice — the rows in the strip and the panel under them — and charged the photo a row for the saying. The path under the bar (.crumb) already names the panel the ruler came from and is one tap from its strip, so the ruler now stands in the strip's slot and the strip stands down with the bar: on 390x844 LIGHT's WB strip is 42px and the photo 639px, COLOR TEMP's ruler 60px and the photo 621px, the path TABS > LIGHT > WB > COLOR TEMP walking back to the strip and then to the bar. A sub-chip also wore the initials of its own label on a tile over it — COLOR TEMP as CT, PRO NEG HI as PNH — which is the name the pill already prints, spelled twice and set 60px tall: LIGHT's WB strip measured 73px on that tile, 42px as the name alone. The ruler's own "<" goes with them: the path names the strip it would reopen, and a wide screen draws neither the path nor a ruler in the bar. Checks: - npm run build (tsc --noEmit + vite) clean: dist/assets/index-2IP_1LL_.css 71.42 kB. - Chromium 390x844, light and dark, LIGHT: the bar 40px / the photo 641px; WB open, the bar gone and its strip 42px in the slot, the photo 639px; COLOR TEMP's ruler 60px in that slot, the photo 621px, the path TABS > LIGHT > WB > COLOR TEMP; crumb-strip lands back on the WB strip and crumb-tab on the bar at 641px. No page error and no sideways scroll (390px of 390px) in any state; on 1280x900 .dev-panels is painted and .dev-chips/.crumb are not.
4030 lines
176 KiB
TypeScript
4030 lines
176 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { TABS, 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 { CameraView } from './ui/CameraView';
|
||
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 { developRaw, isRawName } from './engine/rawDevelop';
|
||
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
|
||
import { getPhoto, loadEdit, readPhotoFile, saveEdit } from './engine/library';
|
||
import { offerInstall } from './pwa/install';
|
||
import { cameraSupported } from './engine/camera';
|
||
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 } 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,
|
||
readableInk,
|
||
rgbToHsl,
|
||
temperatureSwatch,
|
||
} 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 { readHistogram, autoExposureStops, autoTone, autoWhiteBalance } from './ui/Histogram';
|
||
import { DevelopPanels } from './ui/DevelopPanels';
|
||
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.
|
||
//
|
||
// Measured, 2048px source cropped and blown back up, model vs plain filter:
|
||
// at 2x PSNR/SSIM 18.27/0.3036 against the filter's 18.71/0.3792, at 3x
|
||
// 17.96/0.2570 against 18.14/0.2782 — the model's smoothness reads as plastic
|
||
// skin and lost texture next to a plain resample. Only from 4x, where the
|
||
// pixels truly do not exist, does it stop losing (17.60/0.2305 vs
|
||
// 17.84/0.2258). So the model is held back to 4x, its own factor.
|
||
const MODEL_GAIN = 4;
|
||
// 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 },
|
||
];
|
||
|
||
// The two fields a preset button needs — the ruler's own swatch as the button's
|
||
// fill, and the ink that fill can carry as a label (colorUtils.readableInk). The
|
||
// WB table and the TEMP strip both read a preset through this, so the seven
|
||
// presets cannot end up painted two ways.
|
||
function wbSwatch(kelvin: number): { bg: string; fg: string } {
|
||
const bg = temperatureSwatch(kelvin);
|
||
return { bg, fg: readableInk(bg) };
|
||
}
|
||
|
||
// 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 way back: a band chip hands the eyedropper's readout the colour it is
|
||
// named after, and that readout is RGB bytes.
|
||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||
const n = parseInt(hex.slice(1), 16);
|
||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||
}
|
||
|
||
// The knobs that drive a colour get a swatch under their ruler, so the hue is
|
||
// visible next to the number. TEMP is the only one left on this path: TINT's row
|
||
// lives in the sidebar's WB panel now, so its ±10 axis draws no ruler here.
|
||
const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
|
||
temperature: temperatureSwatch,
|
||
};
|
||
|
||
// The option strips, one open at a time (phone's GroupKey set minus the groups
|
||
// the web engine has no knob for). Two strips are of another kind: FIX holds the
|
||
// two brushes, GRADIENT MASK the two shapes, and their chips arm the POINTER
|
||
// rather than pick a value — so they open a strip like the rest and are not
|
||
// entries in groupDefs, which is where the strips that pick keep their options.
|
||
type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'grain' | 'wm'
|
||
| 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'gpsFont';
|
||
type ToolStrip = 'fix' | 'gradient';
|
||
// LIGHT's four panels, as strips of their own: the phone reaches a knob through
|
||
// its panel (LIGHT -> WB -> COLOR TEMP), so each panel is a level of the same
|
||
// cascade the other tabs' chips already walk. The wide screen draws the same
|
||
// four as the column's disclosures (see ui/DevelopPanels).
|
||
type DevPanel = 'wb' | 'tone' | 'presence' | 'effects';
|
||
type StripKey = GroupKey | ToolStrip | DevPanel;
|
||
const WM_SUBGROUPS: StripKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont'];
|
||
const isWmPanel = (g: StripKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
|
||
// A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP).
|
||
// Every knob LIGHT owns names the panel it was opened from, so its ruler's back
|
||
// reopens that panel's strip rather than dropping the strip off the screen.
|
||
const PARAM_GROUP: Partial<Record<string, StripKey>> = {
|
||
temperature: 'wb',
|
||
tint: 'wb',
|
||
ev: 'tone',
|
||
exposure: 'tone',
|
||
contrast: 'tone',
|
||
highlight: 'tone',
|
||
shadow: 'tone',
|
||
whites: 'tone',
|
||
blacks: 'tone',
|
||
clarity: 'presence',
|
||
dehaze: 'presence',
|
||
vibrance: 'presence',
|
||
color: 'presence',
|
||
denoise: 'effects',
|
||
sharpening: 'effects',
|
||
vignette: 'effects',
|
||
hdf: 'effects',
|
||
grain: 'effects',
|
||
grainSize: 'effects',
|
||
// The curve is the graph on the photo, not a row: it opens no ruler, but the
|
||
// TONE strip it came from stays up while it is on.
|
||
curve: 'tone',
|
||
straighten: 'rotate',
|
||
};
|
||
|
||
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' },
|
||
];
|
||
|
||
// LIGHT's stack as a strip reads it: the panel, its name, and the rows it owns —
|
||
// the same four DevelopPanels draws on a wide screen, in the same order. The
|
||
// picks that are not rows (WB's presets and chromes, TONE's curve, the D.RANGE
|
||
// stops) belong to a panel too, so stripChips adds them where the panel they
|
||
// close is; the table is only the rows.
|
||
const DEV_PANELS: { key: DevPanel; label: string; rows: string[] }[] = [
|
||
{ key: 'wb', label: 'WB', rows: ['temperature', 'tint'] },
|
||
{
|
||
key: 'tone',
|
||
label: 'TONE',
|
||
rows: ['ev', 'exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'],
|
||
},
|
||
{ key: 'presence', label: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] },
|
||
{
|
||
key: 'effects',
|
||
label: 'DETAIL & EFFECTS',
|
||
rows: ['denoise', 'sharpening', 'vignette', 'hdf', 'grain', 'grainSize'],
|
||
},
|
||
];
|
||
|
||
// One lookup for a panel's rows: a chip names the knob by its panel's own def,
|
||
// so a row the phone has and the web has not simply draws nothing.
|
||
const DEV_DEF = new Map(
|
||
[...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters].map((def) => [def.key, def])
|
||
);
|
||
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;
|
||
flipH: boolean;
|
||
flipV: boolean;
|
||
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>(() => {
|
||
// The stage opens on the photo itself — the neutral stock, every knob at its
|
||
// default and no film filter, the same recipe COMPARE splits against — so a
|
||
// file that is dropped in is shown as the file. The bundled CLASSIC NEGIPES
|
||
// preset used to sit here as the boot look, and it baked exposure +2 (+0.5
|
||
// EV) plus a 6300K / tint +2 white balance onto every photo the moment it
|
||
// landed: that is what blew the highlights and turned skin pink on a fresh
|
||
// NEF. A look is now there only because a chip was clicked.
|
||
const boot = DEFAULT_RECIPES[0];
|
||
// A session written by that old boot still holds the preset verbatim, which
|
||
// was never the visitor's choice; it is dropped instead of restored. Any
|
||
// edit to a knob moved the stored object off this exact fingerprint.
|
||
const stale = !!boot && session.recipe?.id === 'web-current' &&
|
||
session.recipe.baseFilter === boot.baseFilter &&
|
||
JSON.stringify(session.recipe.adjustments) === JSON.stringify(boot.adjustments);
|
||
return { ...BASE_RECIPE, id: 'web-current', ...(stale ? null : session.recipe) };
|
||
});
|
||
const [simId, setSimId] = useState<string | null>(() => session.simId ?? null);
|
||
// HOVER PREVIEW: the look of the sim or recipe the pointer is over, laid on the
|
||
// photo for as long as it stays there. It never becomes `recipe`, so SAVE,
|
||
// UNDO and every knob still speak about the look the click left behind.
|
||
const [preview, setPreview] = useState<Recipe | null>(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);
|
||
// FRAME's two mirrors: left<->right and top<->bottom. Same pixels, so like
|
||
// STRAIGHTEN they leave the dimensions alone and the crop stays valid — and
|
||
// they ride on top of the quarter turn and the angle instead of replacing it.
|
||
const [flipH, setFlipH] = useState(() => session.flipH ?? false);
|
||
const [flipV, setFlipV] = useState(() => session.flipV ?? false);
|
||
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<StripKey | null>(null);
|
||
// The phone's bottom bar holds one strip at a time: the rail of tabs while it
|
||
// is false, and the picked tab's own chips in the same place while it is true
|
||
// (see .workspace.strip-open in app.css). Desktop reads the rail and the chips
|
||
// side by side and never looks at this.
|
||
const [stripOpen, setStripOpen] = useState(false);
|
||
// 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, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle });
|
||
}, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, 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, flipH, flipV, 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);
|
||
setFlipH(l.flipH);
|
||
setFlipV(l.flipV);
|
||
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,
|
||
flipH: false,
|
||
flipV: false,
|
||
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);
|
||
// `api.me()` has answered — the account is known, guest or not. Opening a
|
||
// photo asks a tier question (see `loadFile`), so the session's photo is
|
||
// restored only once this is true; before that `user` is null and every
|
||
// account reads as a guest.
|
||
const [authReady, setAuthReady] = useState(false);
|
||
// Three tiers, one account each (see config/tiers). Signed in is the basic
|
||
// tier: recipes, the photo folder, a clean export of the photo it is editing.
|
||
// PRO is the box the operator ticks in the admin users table — HSL, TOOLS,
|
||
// GRADIENT MASK, RAW, and an export past the photo's own pixels. An
|
||
// allowlisted admin is the operator, so it counts as PRO by construction.
|
||
const member = !!user;
|
||
// `admin` as well as `pro`: the server already answers PRO for an allowlisted
|
||
// operator, but a deployment mid-rollout can serve an API older than this
|
||
// bundle, and the operator must never be the one account that reads as basic.
|
||
const pro = !!user?.pro || !!user?.admin;
|
||
// The export ceiling the menu and the file both obey: none for PRO, the
|
||
// photo's own long edge for a basic account, the tier's 2048 for a guest.
|
||
const exportCap = pro ? null : member ? editedLongest : TIERS.guest.maxExportDim;
|
||
// 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.
|
||
// A typed PLACE NAME stands on its own: a photo that carried no GPS still
|
||
// stamps the name (with the photo's own date), so the switch never sits ON
|
||
// over a photo with nothing drawn on it. NaN coordinates are what keeps the
|
||
// "print the fix" fallback off — the stamp, gpsLines and the box all test
|
||
// them — while locality carries the line.
|
||
const gpsInfo = useMemo(
|
||
() =>
|
||
gps
|
||
? { ...gps, locality: place.trim() || gps.locality }
|
||
: place.trim()
|
||
? { latitude: NaN, longitude: NaN, locality: place.trim(), timestamp: capturedAt ?? Date.now() }
|
||
: null,
|
||
[gps, place, capturedAt]
|
||
);
|
||
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);
|
||
// The catalogue frame on the stage, if any (`/library` hands one over as
|
||
// `/app?lib=<id>`). It is what the recipe is filed back against, so the frame
|
||
// reopens in the studio at the look it was left at.
|
||
const [libPhotoId, setLibPhotoId] = useState<string | 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);
|
||
// The live viewfinder is open. It grades the camera's feed with the recipe in
|
||
// force, so shooting is the one path where the look is chosen before the
|
||
// picture exists.
|
||
const [shooting, setShooting] = 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 account that is already
|
||
// signed in has the basic tier, which no dialog here can change, so it is
|
||
// told where the grant comes from instead.
|
||
const promptPro = useCallback(() => {
|
||
if (!user) {
|
||
setAuthMode('login');
|
||
return;
|
||
}
|
||
setError(t('pro.locked'));
|
||
}, [user, t]);
|
||
|
||
// 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) => {
|
||
if (!alive) return;
|
||
// The account and the flag that says the account is known land in ONE
|
||
// batch: the handover effect below then sees a render that already
|
||
// carries `user`, so a PRO opening a RAW is not read as a guest.
|
||
setUser(r.user);
|
||
setAuthReady(true);
|
||
})
|
||
.catch(() => alive && setAuthReady(true)); // signed out is a valid state — the demo needs no account
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// The photo half of the session, back out of IndexedDB. It waits for the
|
||
// account: which file may open depends on the tier, and the check reads it
|
||
// from the render — run it too early and a PRO's own RAW is turned away.
|
||
// When the stored session is empty but a RAW is still parked in OPFS, the RAW
|
||
// is what "the last photo" means: develop it again rather than open on
|
||
// nothing. A frame handed over by the catalogue (`/app?lib=<id>`) wins over
|
||
// both: the visitor just clicked it, and it is newer than anything the
|
||
// session holds.
|
||
useEffect(() => {
|
||
if (!authReady) return;
|
||
let alive = true;
|
||
const wanted = new URLSearchParams(window.location.search).get('lib');
|
||
if (wanted) openLibraryPhoto(wanted);
|
||
else
|
||
loadPhoto()
|
||
.then(async (photo) => {
|
||
if (!alive) return;
|
||
if (photo) {
|
||
return adoptPhoto(photo.bytes, photo.name, new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), true);
|
||
}
|
||
const parked = await readRaw();
|
||
if (!alive || !parked) return;
|
||
const jpeg = await developRaw(parked.bytes);
|
||
await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes);
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [authReady]);
|
||
|
||
useEffect(() => {
|
||
// The account's own two listings: the API answers a guest with a 401, and
|
||
// an unverified account is served like any other basic one (see
|
||
// requireMember there).
|
||
if (!member) {
|
||
setSaved([]);
|
||
setMyPhotos([]);
|
||
return;
|
||
}
|
||
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
|
||
api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([]));
|
||
}, [member]);
|
||
|
||
// --- 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]);
|
||
|
||
// The geometry and the stamps a render carries, apart from its recipe: the
|
||
// preview, the compare copy and the live camera all render with THIS one
|
||
// object, so the look the user frames against is the look the file gets.
|
||
const stageOptions = useMemo(
|
||
() => ({
|
||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||
sharpen: true,
|
||
photoRotation: rotation,
|
||
photoStraighten: straighten,
|
||
photoFlipH: flipH,
|
||
photoFlipV: flipV,
|
||
cropRect: cropLive ? cropRect : null,
|
||
watermark: markOn && markText ? { text: markText, ...markStyle } : null,
|
||
gpsWatermark: useGeotag && gpsInfo ? { ...gpsStyle } : null,
|
||
}),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[frameId, cropLive, cropRect, rotation, straighten, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsInfo, gpsStyle]
|
||
);
|
||
|
||
// What the stage paints: the hovered look while the pointer is on a sim or a
|
||
// recipe chip, the user's own look otherwise. The hovered look brings its
|
||
// colour alone — the masks, the dust spots and the mosaic of the photo being
|
||
// edited stay on it, or a pointer crossing a chip row would rub them off.
|
||
const shownRecipe = preview
|
||
? { ...preview, adjustments: { ...preview.adjustments, masks, heal: healSpots, mosaic: mosaicSpots } }
|
||
: recipe;
|
||
|
||
useEffect(() => {
|
||
// While the viewfinder is up it grades the camera's frames with this very
|
||
// recipe, so a preview behind it would only compete for the same renderer —
|
||
// and the user is watching the live one. It comes back with the camera.
|
||
if (!engine || !previewBytes || shooting) 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 = stageOptions;
|
||
const out = await renderPhoto({
|
||
source: previewBytes,
|
||
recipe: shownRecipe,
|
||
// 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, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]);
|
||
|
||
// 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, exifBytes?: Uint8Array) => {
|
||
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
||
// A RAW is decoded to a JPEG with no EXIF of its own, so the tags its
|
||
// stamps read (date, position, ISO) come off the camera file instead.
|
||
const tagged = exifBytes ?? bytes;
|
||
const found = await readGps(tagged);
|
||
const foundIso = await readIso(tagged);
|
||
// 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(tagged);
|
||
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]
|
||
);
|
||
|
||
// A shot, from the live viewfinder into the studio. The recipe is already in
|
||
// force, so the picture arrives wearing the look the user framed: nothing is
|
||
// re-applied here, and the file is written later by the same export the
|
||
// dropped photos use — on the still, not on a frame of that small video.
|
||
const takeShot = useCallback(
|
||
async (bytes: Uint8Array, name: string, blob: Blob) => {
|
||
setShooting(false);
|
||
setError(null);
|
||
setBusy(t('auth.busy'));
|
||
setOpenPhotoId(null);
|
||
setLibPhotoId(null);
|
||
try {
|
||
await adoptPhoto(bytes, name, blob);
|
||
// A shot survives a reload like any other photo of the session's own.
|
||
void savePhoto(name, bytes);
|
||
} catch {
|
||
setError(t('err.decode'));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
},
|
||
[t, adoptPhoto]
|
||
);
|
||
|
||
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. Same for a catalogue frame —
|
||
// the caller that hands one over names it again on the way back.
|
||
setOpenPhotoId(null);
|
||
setLibPhotoId(null);
|
||
// A RAW is PRO: the file is parked whole and developed by the engine,
|
||
// which only that tier carries. A basic account is told, not silently
|
||
// served a JPEG it never asked for.
|
||
if (isRawName(file.name) && !pro) {
|
||
setBusy(null);
|
||
promptPro();
|
||
return;
|
||
}
|
||
try {
|
||
const bytes = await fileToBytes(file);
|
||
if (isRawName(file.name)) {
|
||
// A camera RAW: parked in OPFS whole, developed to the JPEG the studio
|
||
// works on, and remembered in that same shape by the session — so the
|
||
// 30MB is read once, not once per reload.
|
||
await storeRaw(bytes, file.name);
|
||
const jpeg = await developRaw(bytes);
|
||
await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes);
|
||
void savePhoto(file.name, jpeg);
|
||
return;
|
||
}
|
||
await clearRaw();
|
||
await adoptPhoto(bytes, file.name, file);
|
||
void savePhoto(file.name, bytes);
|
||
} catch {
|
||
setError(isRawName(file.name) ? t('err.raw') : t('err.decode'));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
},
|
||
[t, adoptPhoto, pro, promptPro]
|
||
);
|
||
|
||
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]);
|
||
// DELETE takes the chosen shape off the photo, the key the chip beside the
|
||
// photo already carries: a shape is chosen by pressing it on the picture, and
|
||
// the hand that chose it is on the picture, not on the column.
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||
if (e.ctrlKey || e.metaKey || e.altKey || maskSel === null) return;
|
||
const el = e.target as HTMLElement | null;
|
||
if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return;
|
||
e.preventDefault();
|
||
deleteMask();
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [deleteMask, maskSel]);
|
||
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 (!member) {
|
||
promptPro();
|
||
return;
|
||
}
|
||
setNaming('recipe');
|
||
}, [member, 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 (!member) {
|
||
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) }));
|
||
}
|
||
},
|
||
[member, 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 (!member) return;
|
||
try {
|
||
const stored = await api.createRecipe(entry.name, entry);
|
||
setSaved((list) => [stored.recipe, ...list]);
|
||
} catch (err) {
|
||
setError(t('err.generic', { msg: String(err) }));
|
||
}
|
||
},
|
||
[member, 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 (!member) {
|
||
promptPro();
|
||
return;
|
||
}
|
||
const xml = exportRecipeXml({ ...draft, id: 'share' });
|
||
download(
|
||
new TextEncoder().encode(xml),
|
||
`${(draft.name || 'recipe').replace(/\W+/g, '-').toLowerCase()}.recipe`,
|
||
'application/xml'
|
||
);
|
||
},
|
||
[member, 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 = pro ? TIERS.pro : member ? TIERS.member : TIERS.guest;
|
||
// The menu's answer, under the tier's ceiling. UNCHANGED is `null`, and
|
||
// the ceiling a basic account carries is the photo it is editing: the
|
||
// pixels past its own long edge are the model's, and they are PRO's.
|
||
const wanted = target === null ? null : Math.min(target, exportCap ?? 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 (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) {
|
||
// Within the model's gain the plain filter is the better picture (see
|
||
// MODEL_GAIN), so this covers every other case: down, and a hair or a
|
||
// few times up. The whole photo is resampled and the render cuts 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,
|
||
photoFlipH: flipH,
|
||
photoFlipV: flipV,
|
||
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 && gpsInfo ? { ...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`);
|
||
// The moment the app has done the visitor's work is the moment it has earned
|
||
// a place on the machine: the install offer waits for this instead of
|
||
// opening on the way in (which nobody reads).
|
||
offerInstall();
|
||
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, exportCap, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, member, 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 (!member) {
|
||
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 {
|
||
// No tier ceiling to apply here: both saving tiers are uncapped, and the
|
||
// upload's own byte cap is api.ts's (see uploadBytes).
|
||
const out = await renderPhoto({
|
||
source: source.bytes,
|
||
recipe,
|
||
frameId: cropEditing ? 'none' : frameId,
|
||
useGeotag,
|
||
gpsInfo,
|
||
assets: engine.assets,
|
||
options: {
|
||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||
dpi: 300,
|
||
sharpen: true,
|
||
photoRotation: rotation,
|
||
photoStraighten: straighten,
|
||
photoFlipH: flipH,
|
||
photoFlipV: flipV,
|
||
cropRect: cropLive ? cropRect : null,
|
||
watermark: markOn && markText ? { text: markText, ...markStyle } : null,
|
||
gpsWatermark: useGeotag && gpsInfo ? { ...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: source.bytes,
|
||
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,
|
||
photoFlipH: flipH,
|
||
photoFlipV: flipV,
|
||
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);
|
||
}
|
||
}, [member, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, 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 < (exportCap ?? 4096)) preloadSuperRes();
|
||
setChoosingExport(true);
|
||
}, [engine, source, pro, exportCap, 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]
|
||
);
|
||
|
||
// A frame from the local catalogue, handed over as `/app?lib=<id>`: the bytes
|
||
// come off the disk through the handle the catalogue kept, then the recipe that
|
||
// frame was last left at goes on top. A frame first opened has no recipe yet
|
||
// and arrives on the neutral stock, like any other file.
|
||
const openLibraryPhoto = useCallback(
|
||
async (id: string) => {
|
||
setBusy(t('auth.busy'));
|
||
setError(null);
|
||
// The address is a handover, not a state: the frame lives in the session
|
||
// from here on, so a reload reopens it like any other photo on the stage.
|
||
window.history.replaceState(null, '', window.location.pathname);
|
||
clearPreview();
|
||
setHistPick(null);
|
||
try {
|
||
const row = await getPhoto(id);
|
||
if (!row) throw new Error('not in the catalogue');
|
||
await loadFile(await readPhotoFile(row));
|
||
const saved = await loadEdit(id);
|
||
if (saved) applyRecipe(saved.recipe);
|
||
setLibPhotoId(id);
|
||
} catch {
|
||
setError(t('err.decode'));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
},
|
||
[loadFile, applyRecipe, clearPreview, t]
|
||
);
|
||
|
||
// What the catalogue remembers about a frame is the look it was left at, filed
|
||
// back on every change — debounced, because a slider drag fires this per frame
|
||
// of the drag and IndexedDB is on the wrong side of that.
|
||
useEffect(() => {
|
||
if (!libPhotoId || !source) return;
|
||
const timer = setTimeout(() => void saveEdit(libPhotoId, recipe), 600);
|
||
return () => clearTimeout(timer);
|
||
}, [libPhotoId, source, recipe]);
|
||
|
||
// 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);
|
||
// Another chip means another thing being worked on, so the mask tool goes
|
||
// down with it: the mask's chips and its own column belong to that tool,
|
||
// and a photo that is still armed to draw masks is not what a hand
|
||
// reaching for a knob is asking for.
|
||
setMaskTool(null);
|
||
// 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: StripKey) => {
|
||
const next = openGroup === g ? null : g;
|
||
setMaskTool(null);
|
||
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]);
|
||
|
||
// The mask's column belongs to the armed shape the same way: reaching for another
|
||
// chip or tab puts the tool down and takes its column off the stage in one gesture.
|
||
// The shape's own chips are left out — toggling one is its own onClick's business —
|
||
// and anything inside the column (or on the photo, where the shape is drawn) stays.
|
||
useEffect(() => {
|
||
if (!maskTool) return;
|
||
const dismiss = (e: PointerEvent) => {
|
||
const el = e.target as HTMLElement | null;
|
||
if (
|
||
!el?.closest('.rail, .chip') ||
|
||
el.closest('[data-col="mask"], .chip[data-key="linear"], .chip[data-key="radial"]')
|
||
)
|
||
return;
|
||
setMaskTool(null);
|
||
};
|
||
window.addEventListener('pointerdown', dismiss, true);
|
||
return () => window.removeEventListener('pointerdown', dismiss, true);
|
||
}, [maskTool]);
|
||
|
||
// Continuous parameter chips: label + live value; amber text when edited,
|
||
// amber border while its ruler is open.
|
||
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`;
|
||
};
|
||
// 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; bg?: string; fg?: string }[];
|
||
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 and mirrors included.
|
||
if (v === 'reset') {
|
||
setStraighten(0);
|
||
setFlipH(false);
|
||
setFlipV(false);
|
||
}
|
||
},
|
||
},
|
||
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. The presets carry the same two fields the WB table
|
||
// uses — the strip is laid out as a column, so there they stack rather than
|
||
// pair off, but a preset is painted the same either way.
|
||
temp: {
|
||
label: 'TEMP', off: 'auto', value: wbValue(),
|
||
options: [
|
||
{ v: 'color-temp', d: 'COLOR TEMP' },
|
||
...WB_PRESETS.map((p) => ({ v: p.key, d: p.label, ...wbSwatch(p.kelvin) })),
|
||
],
|
||
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 — unless
|
||
// the caller says the chip always names one, which the two Color Chromes do:
|
||
// the pair is read against each other, and OFF is one of the two values being
|
||
// compared.
|
||
const groupChip = (g: Exclude<GroupKey, 'wm'>, nameAlways = false): ChipDef => {
|
||
const { label, value, off, options } = groupDefs[g];
|
||
const picked = options.find((o) => o.v === value);
|
||
return {
|
||
key: `grp-${g}`,
|
||
label,
|
||
value: value !== off || nameAlways ? picked?.d ?? value : undefined,
|
||
active: openGroup === g,
|
||
amberValue: openGroup !== g && value !== off,
|
||
onClick: () => toggleGroup(g),
|
||
};
|
||
};
|
||
|
||
// The two Color Chromes, side by side at the foot of the WB panel: one row of
|
||
// two chips, each naming the strength it is on. They are a pair, not a strip —
|
||
// COLOR CHROME and CHROME BLUE are the same switch over two axes of the same
|
||
// colour, so they are read against each other rather than scanned in turn, and
|
||
// they wear the table layout the WB presets wear (see .chip-row.grid /
|
||
// ChipRow's `grid`). Either one opens its own strip of values in the last
|
||
// column, the same strip it has always been (see stripChips).
|
||
const chromeChips: ChipDef[] = [groupChip('cx', true), groupChip('cxb', true)];
|
||
|
||
// The pair a chip carries when its option is a whole look: the photo takes the
|
||
// look while the pointer rests on the chip, and gives it back when it leaves.
|
||
const lookPreview = (look: Recipe): Pick<ChipDef, 'onHover' | 'onHoverEnd'> => ({
|
||
onHover: () => setPreview(look),
|
||
onHoverEnd: () => setPreview(null),
|
||
});
|
||
|
||
// PHOTO STYLE's own pair, off the sim the chip names. A PRO sim stays out of
|
||
// it: a hover that showed its look would hand over what the click gates.
|
||
const simPreview = (id: string): Pick<ChipDef, 'onHover' | 'onHoverEnd'> => {
|
||
const sim = FILM_SIMS.find((s) => s.id === id);
|
||
if (!sim || (PRO_SIMS.includes(sim.id) && !pro)) return {};
|
||
return lookPreview(filmSimToRecipe(sim));
|
||
};
|
||
|
||
const choiceChips = (
|
||
prefix: string,
|
||
options: { v: string; d: string; pro?: boolean; bg?: string; fg?: string }[],
|
||
current: string,
|
||
onPick: (v: string) => void,
|
||
hover?: (v: string) => Partial<ChipDef>
|
||
): ChipDef[] =>
|
||
options.map((o) => ({
|
||
key: `${prefix}:${o.v}`,
|
||
label: o.d,
|
||
active: current === o.v,
|
||
pro: o.pro,
|
||
// A fill rides through where the caller has one (TEMP's presets); a group
|
||
// that names no colour draws the plain chip, as before.
|
||
...(o.bg ? { bg: o.bg, fg: o.fg } : {}),
|
||
onClick: () => onPick(o.v),
|
||
...(hover?.(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
|
||
|| flipH || flipV
|
||
|| 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, flipH, flipV, markOn, markText, markStyle, useGeotag, gpsStyle]);
|
||
|
||
// The chosen mask's knobs, as the app's own rulers: one parameter to a row,
|
||
// the same row every other number in the app is edited with. EXPOSURE is the
|
||
// spec's EV (-5..+5, its own `pow(2.0, e)`); CONTRAST and SATURATION are the
|
||
// app's -10..+10, which the shader reads as the spec's -1..+1; and a radial
|
||
// mask adds the feather it fades over — the percentage of its own axis the
|
||
// alpha holds full before it dies at the rim.
|
||
// The spec's section 4 knobs, one row each: HIGHLIGHT and SHADOW through the
|
||
// soft masks the shader builds from the pixel's own luma, WHITE and BLACK as
|
||
// the per-channel point moves the frame-wide rows make (the toe and the
|
||
// shoulder, not a gain on the whole pixel), and the two spatial ones — CLARITY
|
||
// against the frame's blurred reference, DEHAZE through the dark channel. Each
|
||
// one means inside the mask what the row of the same name means on the whole
|
||
// frame, down to the -100..+100 the frame-wide rows read (the mask keeps the
|
||
// look's own ±10 unit, and the ×10 is the slider's — see paramDefs). TINT is on
|
||
// this helper too: same ±10 unit, same ×10 ruler, same green↔magenta gain, read
|
||
// on the mask's pixels — a local white balance.
|
||
const maskKnobRow = (
|
||
key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze' | 'tint',
|
||
label: string
|
||
): SliderDef => {
|
||
const v = selMask?.[key] ?? 0;
|
||
const wide = Math.round(v * 10);
|
||
return {
|
||
key: `mask-${key}`,
|
||
label,
|
||
value: wide,
|
||
min: -100,
|
||
max: 100,
|
||
step: 1,
|
||
display: wide > 0 ? `+${wide}` : String(wide),
|
||
edited: v !== 0,
|
||
onChange: (n: number) => setMaskKnob({ [key]: Math.round(n) / 10 } as Partial<GradientMask>),
|
||
onReset: () => setMaskKnob({ [key]: 0 } as Partial<GradientMask>),
|
||
};
|
||
};
|
||
|
||
const maskRulers: SliderDef[] = !selMask
|
||
? []
|
||
: [
|
||
// The WB pair first, in the LIGHT column's own place and on its own
|
||
// scales: kelvin 2500..10000 as itself (a temperature is not a percentage,
|
||
// so it keeps its own ruler), and TINT through the same ×10 the frame-wide
|
||
// row wears. Same two knobs, same gain, the mask's pixels only.
|
||
{
|
||
key: 'mask-temperature',
|
||
label: 'COLOR TEMP',
|
||
value: selMask.temperature ?? 5500,
|
||
min: 2500,
|
||
max: 10000,
|
||
step: 100,
|
||
display: `${selMask.temperature ?? 5500}K`,
|
||
swatch: temperatureSwatch(selMask.temperature ?? 5500),
|
||
edited: (selMask.temperature ?? 5500) !== 5500,
|
||
onChange: (v) => setMaskKnob({ temperature: v }),
|
||
onReset: () => setMaskKnob({ temperature: 5500 }),
|
||
},
|
||
maskKnobRow('tint', 'TINT'),
|
||
{
|
||
key: 'mask-exposure',
|
||
label: 'EXPOSURE',
|
||
value: selMask.exposure,
|
||
min: -MASK_EXPOSURE_MAX,
|
||
max: MASK_EXPOSURE_MAX,
|
||
step: 0.1,
|
||
display: `${selMask.exposure > 0 ? '+' : ''}${selMask.exposure.toFixed(1)}`,
|
||
edited: selMask.exposure !== 0,
|
||
onChange: (v) => setMaskKnob({ exposure: v }),
|
||
onReset: () => setMaskKnob({ exposure: 0 }),
|
||
},
|
||
{
|
||
key: 'mask-contrast',
|
||
label: 'CONTRAST',
|
||
value: Math.round(selMask.contrast * 10),
|
||
min: -100,
|
||
max: 100,
|
||
step: 1,
|
||
display: selMask.contrast > 0 ? `+${Math.round(selMask.contrast * 10)}` : String(Math.round(selMask.contrast * 10)),
|
||
edited: selMask.contrast !== 0,
|
||
onChange: (v) => setMaskKnob({ contrast: Math.round(v) / 10 }),
|
||
onReset: () => setMaskKnob({ contrast: 0 }),
|
||
},
|
||
{
|
||
key: 'mask-saturation',
|
||
label: 'SATURATION',
|
||
value: Math.round(selMask.saturation * 10),
|
||
min: -100,
|
||
max: 100,
|
||
step: 1,
|
||
display: selMask.saturation > 0 ? `+${Math.round(selMask.saturation * 10)}` : String(Math.round(selMask.saturation * 10)),
|
||
edited: selMask.saturation !== 0,
|
||
onChange: (v) => setMaskKnob({ saturation: Math.round(v) / 10 }),
|
||
onReset: () => setMaskKnob({ saturation: 0 }),
|
||
},
|
||
maskKnobRow('highlights', 'HIGHLIGHT'),
|
||
maskKnobRow('shadows', 'SHADOW'),
|
||
maskKnobRow('whites', 'WHITE'),
|
||
maskKnobRow('blacks', 'BLACK'),
|
||
maskKnobRow('clarity', 'CLARITY'),
|
||
maskKnobRow('dehaze', 'DEHAZE'),
|
||
...(selMask.kind === 'radial'
|
||
? [
|
||
{
|
||
key: 'mask-feather',
|
||
label: 'FEATHER',
|
||
value: Math.round(selMask.feather * 100),
|
||
min: 0,
|
||
max: 100,
|
||
step: 5,
|
||
display: `${Math.round(selMask.feather * 100)}%`,
|
||
edited: selMask.feather !== MASK_DEFAULT_FEATHER,
|
||
onChange: (v: number) => setMaskKnob({ feather: v / 100 }),
|
||
onReset: () => setMaskKnob({ feather: MASK_DEFAULT_FEATHER }),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
|
||
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();
|
||
},
|
||
});
|
||
|
||
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 — then the mirrors riding on top of them.
|
||
{
|
||
key: 'grp-rotate',
|
||
label: 'ROTATE',
|
||
value: [
|
||
straighten !== 0 ? `${rotation}° ${straighten > 0 ? '+' : ''}${straighten}°` : `${rotation}°`,
|
||
...(flipH ? ['H-FLIP'] : []),
|
||
...(flipV ? ['V-FLIP'] : []),
|
||
].join(' '),
|
||
active: openGroup === 'rotate',
|
||
amberValue: openGroup !== 'rotate' && (rotation !== 0 || straighten !== 0 || flipH || flipV),
|
||
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);
|
||
// AUTO (LIGHT tab): the one chip that WRITES values instead of naming one. It
|
||
// measures the LOADED PHOTO — not the preview — so the numbers do not depend
|
||
// on the knobs already in play: pressing it twice lands on the same recipe, the
|
||
// way Lightroom's Auto does. readHistogram gives the binned ramp, which the
|
||
// three readings in ui/Histogram take their answer from: the mean luma for EV,
|
||
// the top and bottom 1% for HIGHLIGHT/SHADOW, the channel means for the WB
|
||
// ruler. AUTO is on LIGHT but it does write WB — the colour cast is part of
|
||
// what the frame is asking for, and the TEMPERATURE/TINT knobs report it.
|
||
const autoTune = async () => {
|
||
if (!previewBytes) return;
|
||
const url = URL.createObjectURL(new Blob([previewBytes as BlobPart], { type: 'image/jpeg' }));
|
||
try {
|
||
const hist = await readHistogram(url, () => true);
|
||
if (hist) {
|
||
remember();
|
||
setAdjustment({
|
||
// EV is the one knob printed to a tenth; the others are whole units.
|
||
exposureCompensation: Math.round(autoExposureStops(hist.lum) * 10) / 10,
|
||
...autoTone(hist.lum),
|
||
...autoWhiteBalance(hist.r, hist.g, hist.b),
|
||
});
|
||
}
|
||
} catch {
|
||
// A photo that will not decode leaves the recipe alone — a broken preview
|
||
// is not worth an error banner here.
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
};
|
||
|
||
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);
|
||
}
|
||
};
|
||
|
||
// A panel's own row, as a chip: the knob by name, its live value under it, and
|
||
// a tap that opens its ruler in the row above the strip (the strip itself
|
||
// stays where it is, so the panel is still under the ruler — see .col-slider
|
||
// in styles/app.css). Amber text once the knob has left its default, amber
|
||
// border while its ruler is the one open, which is how every other chip in
|
||
// the app reads.
|
||
const devRow = (key: string): ChipDef => {
|
||
const def = DEV_DEF.get(key)!;
|
||
const value = def.get(recipe.adjustments);
|
||
// A PRO knob on an account without it is the upgrade path, exactly as the
|
||
// sidebar's locked row is: the chip is there and the tap explains.
|
||
const locked = !!def.pro && !pro;
|
||
return {
|
||
key,
|
||
label: def.label,
|
||
value: def.display(value),
|
||
active: openParam === key,
|
||
amberValue: openParam !== key && value !== def.defaultValue,
|
||
pro: locked || undefined,
|
||
onClick: locked ? promptPro : () => toggleParam(key),
|
||
};
|
||
};
|
||
|
||
// A panel is worth a mark once one of its rows has moved.
|
||
const panelMoved = (panel: { rows: string[] }): boolean =>
|
||
panel.rows.some((key) => {
|
||
const def = DEV_DEF.get(key)!;
|
||
return def.get(recipe.adjustments) !== def.defaultValue;
|
||
});
|
||
|
||
// 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 the chip under RECIPES in the tab's own column.
|
||
];
|
||
case 'favorited':
|
||
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,
|
||
...lookPreview({ ...(entry.recipe as 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 (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':
|
||
// LIGHT's chips ARE its four panels, and the panel's own strip is what
|
||
// opens next (see DEV_PANELS and stripChips): the phone walks
|
||
// LIGHT -> WB -> COLOR TEMP, one strip at a time. Every pick of the tab
|
||
// is inside the panel that owns it — WB's presets with WB, the curve
|
||
// with the range it is the shape of — so the row names the four and the
|
||
// strip does the rest.
|
||
//
|
||
// A wide screen reads the same four as the column's own disclosures
|
||
// (DevelopPanels) and hides this row: two ways to one knob is one too
|
||
// many (see .dev-chips in styles/app.css).
|
||
return DEV_PANELS.map(
|
||
(panel): ChipDef => ({
|
||
key: `dev-${panel.key}`,
|
||
label: panel.label,
|
||
active: openGroup === panel.key,
|
||
amberValue: openGroup !== panel.key && panelMoved(panel),
|
||
onClick: () => toggleGroup(panel.key),
|
||
})
|
||
);
|
||
case 'tools':
|
||
// TOOLS is the tab of the features that write rather than name: AUTO
|
||
// reads the loaded photo and lands a whole set of numbers on it (see
|
||
// autoTune), FIX and GRADIENT MASK take the pointer, MONOCHROME swaps the
|
||
// base filter for the mono stock. They are one kind of thing — a move
|
||
// made on the frame, not a look picked for it — which is why they share
|
||
// a tab instead of LIGHT's row. Each carries its own mark at the head of
|
||
// its label so the four read as one set.
|
||
//
|
||
// Nothing here is a knob, so the tab is this row alone: the strip a chip
|
||
// opens is the last column's (see stripChips), the same as anywhere else.
|
||
// AUTO and MONOCHROME come with any account — one lands a set of numbers
|
||
// on the frame, the other swaps the stock for the mono one. FIX and
|
||
// GRADIENT MASK take the pointer on the frame the way the phone's own
|
||
// edition does, and those two are PRO: a basic account is offered the
|
||
// reason in their place, so the row keeps its four marks either way.
|
||
const locked = (key: string, label: string): ChipDef => ({
|
||
key: `${key}-pro`,
|
||
label,
|
||
pro: true,
|
||
onClick: promptPro,
|
||
});
|
||
return [
|
||
{ key: 'auto', label: '✦ AUTO', onClick: () => void autoTune() },
|
||
// FX's tools are not looks: each is a strip of its own, opened from the
|
||
// row. FIX repairs a speck of dust, MOSAIC hides a patch, GRADIENT MASK
|
||
// draws a shape; the strip's own chip carries the name and glows amber
|
||
// while there is something inside it to clear.
|
||
pro
|
||
? {
|
||
key: 'fix',
|
||
label: '✚ FIX',
|
||
active: openGroup === 'fix',
|
||
amberValue: healSpots.length > 0 || mosaicSpots.length > 0,
|
||
onClick: () => {
|
||
// The tools that take the pointer on the photo never share it.
|
||
setPicking(false);
|
||
setBrushTool(null);
|
||
toggleGroup('fix');
|
||
},
|
||
}
|
||
: locked('fix', t('pro.fixChip')),
|
||
pro
|
||
? {
|
||
key: 'gradient-mask',
|
||
label: '▚ GRADIENT MASK',
|
||
active: openGroup === 'gradient',
|
||
amberValue: masks.length > 0,
|
||
onClick: () => {
|
||
setPicking(false);
|
||
setBrushTool(null);
|
||
toggleGroup('gradient');
|
||
},
|
||
}
|
||
: locked('gradient-mask', t('pro.gradientChip')),
|
||
{ key: 'mono', label: '◑ MONOCHROME', active: monoOn, onClick: toggleMono },
|
||
];
|
||
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); } },
|
||
// PICK is not a band: it reads a colour off the photo to aim the
|
||
// mixer, and every chip under the rule edits what it read, so the
|
||
// rule is where the row stops being the tool and starts being the
|
||
// colour.
|
||
{ key: 'hsl-pick-rule', divider: true, label: '', onClick: () => {} },
|
||
...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,
|
||
// A band chip aims the mixer AND opens its panel, the same panel
|
||
// the eyedropper opens: the colour it starts from is the colour
|
||
// the chip is named after, and this band's knobs push it from
|
||
// there. A pick already on the photo keeps its spot — only a
|
||
// mixer with nowhere to hang takes the middle of the frame.
|
||
onClick: () => {
|
||
setHslBand(b.id);
|
||
setSample(hexToRgb(hslToHex(b.hue, 70, 50)));
|
||
setPickedAt((at) => at ?? { fx: 0.5, fy: 0.5 });
|
||
},
|
||
};
|
||
}),
|
||
// The divider keeps the three image-wide sliders out of the colour
|
||
// row: the rule stands where the colour ends and the knobs that move
|
||
// every colour at once begin, and IMAGE names what they move.
|
||
{ key: 'hsl-image-rule', divider: true, label: '', onClick: () => {} },
|
||
{ 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
|
||
})();
|
||
|
||
// FIX's two brushes: 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.
|
||
const fixChips: ChipDef[] = [
|
||
{ key: 'hint-fix', label: 'FIX', disabled: true, onClick: () => {} },
|
||
{
|
||
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 }] : []),
|
||
];
|
||
|
||
// GRADIENT MASK's two shapes are the tab's other 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.
|
||
const gradientChips: ChipDef[] = [
|
||
{ key: 'hint-gradient', label: 'GRADIENT MASK', disabled: true, onClick: () => {} },
|
||
...(['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 }] : []),
|
||
];
|
||
|
||
// A find alone does not narrow its argument: the guard is what tells the rest
|
||
// of the function that g is a panel key and not a group's.
|
||
const isDevPanel = (k: StripKey): k is DevPanel => DEV_PANELS.some((p) => p.key === k);
|
||
|
||
const stripChips = (g: Exclude<StripKey, 'wm'>): ChipDef[] => {
|
||
// The two tool strips hold chips that arm the pointer rather than options to
|
||
// pick, so groupDefs has nothing to say about them.
|
||
if (g === 'fix') return fixChips;
|
||
if (g === 'gradient') return gradientChips;
|
||
// LIGHT's four panels: the panel's rows first — each a knob that opens its
|
||
// ruler above — then the picks that close it, because a panel is read as its
|
||
// rows and the picks its name is about.
|
||
if (isDevPanel(g)) {
|
||
// The guard has just said the panel is in the table.
|
||
const panel = DEV_PANELS.find((p) => p.key === g) as (typeof DEV_PANELS)[number];
|
||
return [
|
||
{ key: `hint-${g}`, label: panel.label, disabled: true, onClick: () => {} },
|
||
...panel.rows.map(devRow),
|
||
// WB closes on its presets and the two Color Chromes: a preset IS a cast
|
||
// (the swatch is painted on the button), and the chromes are the other
|
||
// axis of the two tracks above them.
|
||
...(g === 'wb'
|
||
? [...choiceChips('temp', groupDefs.temp.options, groupDefs.temp.value, groupDefs.temp.onPick), ...chromeChips]
|
||
: []),
|
||
// TONE closes on its curve: the graph on the photo is the shape of the
|
||
// range the seven rows above it are.
|
||
...(g === 'tone'
|
||
? [
|
||
{
|
||
key: 'curve',
|
||
label: '∿ TONE CURVE',
|
||
active: openParam === 'curve',
|
||
amberValue: openParam !== 'curve' && curveIsActive(recipe.adjustments.toneCurve),
|
||
onClick: () => toggleParam('curve'),
|
||
},
|
||
]
|
||
: []),
|
||
// D.RANGE is a hold on the whole frame, and its four stops are one
|
||
// setting read as a set — so they stand in the strip the effects close
|
||
// rather than opening a strip of their own.
|
||
...(g === 'effects'
|
||
? choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick)
|
||
: []),
|
||
];
|
||
}
|
||
const out: ChipDef[] = [
|
||
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
|
||
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick, g === 'style' ? simPreview : undefined).map((c) => ({
|
||
...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' ? { active: false } : {}),
|
||
})),
|
||
// 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'
|
||
? [
|
||
// The two mirrors are switches, not picks: each stays on whatever the
|
||
// quarter turn and the angle are, so a flip is one tap away from any
|
||
// angle the strip can hold.
|
||
{
|
||
key: 'flip-h',
|
||
label: 'H-FLIP',
|
||
active: flipH,
|
||
onClick: () => {
|
||
remember();
|
||
setFlipH((v) => !v);
|
||
},
|
||
},
|
||
{
|
||
key: 'flip-v',
|
||
label: 'V-FLIP',
|
||
active: flipV,
|
||
onClick: () => {
|
||
remember();
|
||
setFlipV((v) => !v);
|
||
},
|
||
},
|
||
{
|
||
key: 'straighten',
|
||
label: 'STRAIGHTEN',
|
||
active: openParam === 'straighten',
|
||
amberValue: openParam !== 'straighten' && straighten !== 0,
|
||
onClick: () => toggleParam('straighten'),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
return out;
|
||
};
|
||
|
||
// 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 is not one of them: it is not a look to open but the way a look gets
|
||
// into the list, so it stands in the tab's own column under the RECIPES chip
|
||
// that opens the list — the file the phone (or another account) exports, same
|
||
// envelope, same parser, so a look crosses devices intact.
|
||
const importChip: ChipDef = { key: 'recipes-import', label: 'IMPORT .RECIPE', onClick: pickRecipeFile };
|
||
const recipeStrip: ChipDef[] = [
|
||
...saved.map((entry) => ({
|
||
key: `saved-${entry.id}`,
|
||
label: entry.name,
|
||
active: recipe.name === entry.name,
|
||
...lookPreview({ ...(entry.recipe as 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,
|
||
...lookPreview(entry),
|
||
onClick: () => applyRecipe(entry),
|
||
})),
|
||
...DEFAULT_RECIPES.map((preset) => ({
|
||
key: preset.id,
|
||
label: preset.name,
|
||
active: recipe.name === preset.name && !recipe.isCustom,
|
||
drag: preset.id,
|
||
...lookPreview(preset),
|
||
onClick: () => applyRecipe({ ...preset }),
|
||
})),
|
||
];
|
||
|
||
// The studio sidebar's slots (DevelopPanels): the picks that name a whole look
|
||
// rather than a number, so they stay strips of their own inside the panel that
|
||
// owns them — SIM with the profile, the WB presets and then the two Color
|
||
// Chromes with the two WB tracks, D.RANGE in its own box at the foot of the
|
||
// effects. Every one of them is the strip that already existed, so a pick made
|
||
// here is the pick made on the tab it came from.
|
||
const developSlots = {
|
||
// STYLE is not a slot: the film simulation is PRESETS' own chip (see the
|
||
// `style` group), so a PROFILE panel here would be a second way to it.
|
||
//
|
||
// COLOR TEMP is left out: the ruler is the panel's own TEMPERATURE row, so a
|
||
// chip that opened the same ruler would be a second way to the same knob.
|
||
//
|
||
// The presets are a TABLE, not a strip: they are one kind of value read
|
||
// against each other, so they are laid out as two columns of buttons rather
|
||
// than a wrapping row — each button filled with the cast it puts on the
|
||
// frame and labelled in the ink that fill carries, which is what makes a
|
||
// preset recognisable without reading its name. Keyed `wb-temp:` rather than
|
||
// `temp:`, because the strip's own TEMP row can be open beside it and two
|
||
// buttons may not answer to one key.
|
||
//
|
||
// The name is the button's label and the kelvin is its value slot, which the
|
||
// grid's own rule stacks: the standard's name on one line, its cast under it
|
||
// in the same ink a size down, the pair no wider than the name.
|
||
wb: (
|
||
<ChipRow
|
||
grid
|
||
chips={WB_PRESETS.map((p) => ({
|
||
key: `wb-temp:${p.key}`,
|
||
label: p.label,
|
||
value: `${p.kelvin}K`,
|
||
active: wbValue() === p.key,
|
||
...wbSwatch(p.kelvin),
|
||
onClick: () => {
|
||
remember();
|
||
setWbChoice(p.key);
|
||
setAdjustment({ temperature: p.kelvin, tint: p.tint });
|
||
},
|
||
}))}
|
||
/>
|
||
),
|
||
// The two Color Chromes close WB: same switch, the other axis of the two
|
||
// tracks above it, so they are read under the pair they are read against —
|
||
// and on the tab whose colour they are.
|
||
wbFoot: <ChipRow grid chips={chromeChips} />,
|
||
// TONE CURVE closes TONE: the graph on the photo is the shape of the range
|
||
// the panel's seven rows are, so it stands under them rather than in a row
|
||
// of its own at the top of the tab — and it glows amber once the graph is
|
||
// off the diagonal, which is the panel's own state read from its foot.
|
||
toneFoot: (
|
||
<ChipRow
|
||
chips={[
|
||
{
|
||
key: 'curve',
|
||
label: '∿ TONE CURVE',
|
||
active: openParam === 'curve',
|
||
amberValue: curveIsActive(recipe.adjustments.toneCurve),
|
||
onClick: () => toggleParam('curve'),
|
||
},
|
||
]}
|
||
/>
|
||
),
|
||
// D.RANGE closes DETAIL & EFFECTS in a box of its own: it is a hold on the
|
||
// whole frame, so it stands at the foot of the effects rather than with the
|
||
// tone knobs it is read against, and the four stops are one setting read as
|
||
// a set — the box and its name are what say so.
|
||
effects: (
|
||
<div className="dev-box" data-key="dev-box-dr">
|
||
<span className="dev-box-title">DYNAMIC RANGE</span>
|
||
<ChipRow chips={choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick)} />
|
||
</div>
|
||
),
|
||
};
|
||
|
||
// 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[] = [];
|
||
// The coordinates are only a fallback for a fix that carries no name, and a
|
||
// photo whose name was typed in has no coordinates to print.
|
||
const coords = Number.isFinite(gpsInfo.latitude)
|
||
? `${gpsInfo.latitude.toFixed(5)}, ${gpsInfo.longitude.toFixed(5)}`
|
||
: '';
|
||
if (gpsStyle.showName) {
|
||
const name = gpsInfo.locality?.trim() || coords;
|
||
if (name) out.push(name);
|
||
}
|
||
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]);
|
||
|
||
// What a strip calls itself, for the path under the bar. The panels answer
|
||
// with their own names, the two tool strips with theirs, and the rest are the
|
||
// option groups, whose label is the chip that opened them.
|
||
const stripLabel = (g: StripKey): string => {
|
||
const panel = DEV_PANELS.find((p) => p.key === g);
|
||
if (panel) return panel.label;
|
||
if (g === 'fix') return 'FIX';
|
||
if (g === 'gradient') return 'GRADIENT MASK';
|
||
if (g === 'wm') return 'WATERMARK';
|
||
return groupDefs[g as Exclude<GroupKey, 'wm'>].label;
|
||
};
|
||
|
||
// The path down the cascade, one name per level: the rail the bar stands in
|
||
// for, the tab, the strip it opened, the ruler that strip opened. A name
|
||
// returns to the strip it names — TABS back to the rail, LIGHT back to the
|
||
// tab's own chips, WB back to the panel — so there is no back button to hunt
|
||
// for. The last name is where the finger already is, and is a label.
|
||
const crumbs: { key: string; label: string; onClick?: () => void }[] = [
|
||
{
|
||
key: 'tabs',
|
||
label: t('nav.tabs'),
|
||
onClick: () => {
|
||
setStripOpen(false);
|
||
setOpenGroup(null);
|
||
setOpenParam(null);
|
||
},
|
||
},
|
||
{
|
||
key: 'tab',
|
||
label: t(TABS.find((entry) => entry.id === tab)?.label ?? 'nav.studio'),
|
||
onClick: () => {
|
||
setOpenGroup(null);
|
||
setOpenParam(null);
|
||
},
|
||
},
|
||
...(openGroup ? [{ key: 'strip', label: stripLabel(openGroup), onClick: () => setOpenParam(null) }] : []),
|
||
// A ruler is a row, not a strip: its name is the knob's, and the strip it
|
||
// came from is the level above — COLOR TEMP under WB.
|
||
...(openParam
|
||
? [{ key: 'ruler', label: slider?.label ?? DEV_DEF.get(openParam)?.label ?? openParam.toUpperCase() }]
|
||
: []),
|
||
].map((c, i, all) => (i === all.length - 1 ? { ...c, onClick: undefined } : c));
|
||
|
||
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();
|
||
}}
|
||
/>
|
||
|
||
{/* The trial notice: one band under the header, the same shape the verify
|
||
bar below takes. Copy only — nothing here is dismissible, because the
|
||
date IS the message. */}
|
||
<div className="notice-bar" role="status" data-key="trial-notice">
|
||
{t('notice.trial')}
|
||
</div>
|
||
|
||
{/* A signed-in account the operator has not activated has the basic tier,
|
||
so the studio says what that means once, at the top, instead of letting
|
||
each locked chip explain it on its own. An unproven address is the same
|
||
tier — with a button for the letter still standing in the way. */}
|
||
{user && !pro ? (
|
||
<div className="verify-bar" role="status">
|
||
<span>{t('pro.basicBar', { email: user.email })}</span>
|
||
{user.verified ? null : (
|
||
<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${stripOpen ? ' strip-open' : ''}`}>
|
||
{/* The tab opens its own strip in the bottom bar's place; the bar holds
|
||
one strip at a time, so a pick both switches the tab and hands the
|
||
bar over to it (see .strip-open in app.css). */}
|
||
<ToolRail
|
||
tab={tab}
|
||
onTab={(id) => {
|
||
setTab(id);
|
||
setStripOpen(true);
|
||
}}
|
||
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' : ''}${
|
||
tab === 'light' ? ' col-dev' : ''
|
||
}`}
|
||
data-col="main"
|
||
>
|
||
{/* The phone's way back is the path under the strip (see .crumb):
|
||
TABS > LIGHT > WB, each name returning to the strip it names.
|
||
The chip that used to stand here said the same thing with one
|
||
name; the path says how far down the visitor is, which is the
|
||
half of it the glyph could not. */}
|
||
{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 })}
|
||
>
|
||
<span className="chip-label">{entry.name}</span>
|
||
</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)}
|
||
>
|
||
<span className="chip-label" aria-hidden="true">✕</span>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* LIGHT's column is the sidebar itself — every pick of the tab
|
||
is a panel of DevelopPanels — AND its four panels as a chip
|
||
row. Which of the two a screen reads is the size of it: the
|
||
row is the phone's way into a panel (see DEV_PANELS), and
|
||
the column is the wide screen's, where every row is already
|
||
in sight and a row of chips would only repeat the heads. */}
|
||
{tab === 'light' ? (
|
||
<>
|
||
<div className="dev-chips">
|
||
<ChipRow chips={chips} />
|
||
</div>
|
||
<DevelopPanels
|
||
adj={recipe.adjustments}
|
||
set={setAdjustmentOnce}
|
||
pro={pro}
|
||
onPro={promptPro}
|
||
slots={developSlots}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
<ChipRow chips={chips} />
|
||
{/* IMPORT stands under the chip that opens the RECIPES
|
||
list: a look is imported INTO that list, so the way in
|
||
belongs to the chip, not to the list it fills. */}
|
||
{tab === 'presets' && showRecipes ? <ChipRow chips={[importChip]} /> : null}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
{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>
|
||
|
||
{/* 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()}
|
||
>
|
||
<span className="chip-label">{locating ? t('wm.locating') : t('wm.useLocation')}</span>
|
||
</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}
|
||
|
||
{/* FX's GRADIENT MASK strip, once one of its shapes is chosen: the
|
||
shape's own name, the way to take it off the photo, and its knobs as
|
||
the app's own rulers — one parameter to a row. A radial mask adds the
|
||
feather it fades over, since that is the number its rim is made of.
|
||
The column is the mask tool's, so putting that tool down — which is
|
||
what another chip does — takes the column with it. It stands after
|
||
the strip it came out of, the way a ruler stands after its own. Eleven
|
||
rulers are eleven one-line rows, so it takes the ruler column's own
|
||
width (col-slider): in a strip's 168px the tracks would be left with
|
||
nothing but what the labels did not want. */}
|
||
{maskTool && selMask ? (
|
||
<div className="col col-sub strip col-slider" data-col="mask">
|
||
<ChipRow
|
||
chips={[
|
||
{ key: 'mask-kind', label: selMask.kind.toUpperCase(), active: true, onClick: () => {} },
|
||
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
|
||
]}
|
||
/>
|
||
{maskRulers.map((def) => (
|
||
<SliderRow key={def.key} slider={def} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{tab === 'presets' && showRecipes ? (
|
||
<div className="col col-sub strip" data-col="recipes">
|
||
{recipeStrip.length === 0 ? <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>
|
||
|
||
{/* The path back up, under the strip: TABS > LIGHT > WB. Drawn only while
|
||
a strip stands in the rail's place — there is no path to read while
|
||
the rail itself is on the screen — and never on a wide screen, where
|
||
every column is in sight already (see .crumb in styles/app.css). */}
|
||
{stripOpen ? (
|
||
<nav className="crumb" data-key="crumb" aria-label={t('nav.tabs')}>
|
||
{crumbs.map((c, i) => (
|
||
<span className="crumb-part" key={c.key}>
|
||
{i ? <span className="crumb-sep" aria-hidden="true">></span> : null}
|
||
{c.onClick ? (
|
||
<button type="button" className="crumb-btn" data-key={`crumb-${c.key}`} onClick={c.onClick}>
|
||
{c.label}
|
||
</button>
|
||
) : (
|
||
<span className="crumb-here" aria-current="page">{c.label}</span>
|
||
)}
|
||
</span>
|
||
))}
|
||
</nav>
|
||
) : null}
|
||
|
||
{/* The stage's place in the workspace. The viewfinder covers the stage
|
||
while it is open, and nothing else: the rail and the columns stay
|
||
where they are, so a slider moves the look on the LIVE frames — the
|
||
loop reads the recipe again for every one of them. */}
|
||
<div className="stage-slot">
|
||
<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)}
|
||
onCamera={
|
||
// PRO, like the frames and the geotag: the button is there for
|
||
// everyone and the click says why when it is not theirs to open.
|
||
cameraSupported() ? () => (pro ? setShooting(true) : promptPro()) : undefined
|
||
}
|
||
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 && 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
|
||
}
|
||
/>
|
||
{shooting && engine ? (
|
||
<CameraView
|
||
recipe={recipe}
|
||
// A crop being framed is not committed, so the live view composes
|
||
// on the bare photo — the geometry the stage behind it uses.
|
||
frameId={cropEditing ? 'none' : frameId}
|
||
options={stageOptions}
|
||
useGeotag={useGeotag}
|
||
gpsInfo={gpsInfo}
|
||
assets={engine.assets}
|
||
onShot={takeShot}
|
||
onClose={() => setShooting(false)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</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={exportCap}
|
||
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>
|
||
);
|
||
}
|