cb1839e36b
The one path where the look is chosen before the picture exists: OPEN CAMERA grades the camera's own feed with the recipe in force, many times a second, and the shutter hands the studio the sensor's still under that same recipe. Preview and file differ in resolution only — the still is `takePhoto`'s own frame, not a copy of the small preview video, with `grabFrame` and a 2d copy of the element behind it for the browsers that ship no ImageCapture. The renderer gains two inputs for it: `sourceImage`, a picture the caller already decoded (re-encoding the camera's frame to JPEG only to decode it again would cost more than the whole render), and `drawTo`, which paints the finished picture instead of encoding it. One render is in flight at a time; a frame that arrives during one is dropped, so a slow device shows a lower frame rate rather than a queue of moments that have passed. The view flashed black on a phone. Setting width/height on a canvas resets its bitmap: measured on the preview, a resize leaves mean 0 until the next render lands, which on this box is 0.5s and on a phone more. The buffer was sized from every incoming frame, and a capture that renegotiates its resolution — which Chromium does when the page is too slow to consume its frames, and this pipeline runs ~2 fps at 720p under software GL — strobed black/picture at every switch. The buffer is now sized on the first frame and after that only when the frame's aspect changes: a same-aspect frame is scaled into it. Swapping a 1280x720 stream for a 640x360 one mid-view now leaves the buffer at 1280x720 with no black frame, and 640x360 renders at 6-13 fps instead of 2. The frames are read from a <video>, which is now IN the document (1px, behind the black backdrop) rather than detached: Safari draws blank frames from a detached video, which is the same black-between-pictures. It leaves the document with the view, and the tracks are stopped, so the camera light goes out. Probes: cam-smoke (feed painted, resolution, frame rate, a monochrome sim reaching the live frames, shutter into the studio, close, console clean), cam-renegotiate (no resize, no blank frame, status line on the frames), cam-close-flip (flip returns a picture; video gone on close).
3424 lines
146 KiB
TypeScript
3424 lines
146 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { ToolRail, type TabId } from './ui/ToolRail';
|
||
import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
|
||
import { ImageStage } from './ui/ImageStage';
|
||
import { StraightenRuler } from './ui/StraightenRuler';
|
||
import { ToneCurvePanel } from './ui/ToneCurvePanel';
|
||
import { TopBar } from './ui/TopBar';
|
||
import { AuthModal } from './ui/AuthModal';
|
||
import { NameModal } from './ui/NameModal';
|
||
import { DeletePhotoModal } from './ui/DeletePhotoModal';
|
||
import { ClearPhotoModal } from './ui/ClearPhotoModal';
|
||
import { ExportModal } from './ui/ExportModal';
|
||
import { 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 { 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, type ContinuousParamDef } from '../shared/utils/paramDefs';
|
||
import { exportRecipeXml, importRecipeXml } from '../shared/utils/recipeShare';
|
||
import { writeJpegExif } from '../shared/utils/exifWrite';
|
||
import {
|
||
CROP_W_H,
|
||
DEFAULT_CROP_RECT,
|
||
type CropRatio,
|
||
type CropRect,
|
||
type FrameId,
|
||
type GPSInfo,
|
||
type GradientMask,
|
||
type HealSpot,
|
||
type HslBand,
|
||
type HslBandId,
|
||
type MosaicSpot,
|
||
type Recipe,
|
||
} from '../shared/types';
|
||
import { HSL_BANDS, isMonochromeBase, kelvinToRGB, nearestHslBand, rgbToHsl } from '../shared/utils/colorUtils';
|
||
import { grainPerInch, grainStockFor } from '../shared/utils/grainShader';
|
||
import { curveIsActive } from '../shared/utils/toneCurve';
|
||
import { HEAL_DEFAULT_R } from '../shared/utils/heal';
|
||
import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic';
|
||
import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask';
|
||
import { readHistogram, autoExposureStops } from './ui/Histogram';
|
||
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 },
|
||
];
|
||
|
||
// Kelvin -> the CAST the ruler prints. Not the colour of a blackbody at that K:
|
||
// the number names the light the scene was shot under, so the engine compensates
|
||
// for it and WARMS the picture as K rises (kelvinToRGB). A swatch painted with
|
||
// the light's own colour would therefore run the other way from the picture —
|
||
// orange under a knob that blue-d the frame. Measuring the ruler means painting
|
||
// what the render does: the engine's own gains on a mid grey, which is the same
|
||
// matrix step the preview puts on every pixel, so this can never drift from it.
|
||
function temperatureSwatch(k: number): string {
|
||
const g = kelvinToRGB(k);
|
||
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v * 255))).toString(16).padStart(2, '0');
|
||
return `#${chan(0.5 * g.r)}${chan(0.5 * g.g)}${chan(0.5 * g.b)}`;
|
||
}
|
||
|
||
// HSL -> #rrggbb (CSS keeps hsl() opaque to a test, and the swatch prints its
|
||
// own hex, so the panel hands out one format).
|
||
function hslToHex(h: number, s: number, l: number): string {
|
||
const k = (n: number) => (n + h / 30) % 12;
|
||
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
|
||
const chan = (n: number) =>
|
||
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
|
||
.toString(16)
|
||
.padStart(2, '0');
|
||
return `#${chan(0)}${chan(8)}${chan(4)}`;
|
||
}
|
||
|
||
// The knobs that drive a colour get a swatch under their ruler, so the hue is
|
||
// visible next to the number. TINT runs green (-10) through a neutral grey (0)
|
||
// to magenta (+10) — the axis every WB tool labels G..M.
|
||
const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
|
||
temperature: temperatureSwatch,
|
||
tint: (v) => hslToHex(140 + ((v + 10) / 20) * 160, Math.abs(v) * 7, 58 + (10 - Math.abs(v)) * 3),
|
||
};
|
||
|
||
// The option strips, one open at a time (phone's GroupKey set minus the groups
|
||
// the web engine has no knob for). 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';
|
||
type StripKey = GroupKey | ToolStrip;
|
||
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).
|
||
const PARAM_GROUP: Partial<Record<string, GroupKey>> = {
|
||
temperature: 'temp',
|
||
straighten: 'rotate',
|
||
grain: 'grain',
|
||
grainSize: 'grain',
|
||
};
|
||
|
||
const CHROME_OPTIONS = [
|
||
{ v: 'none', d: 'OFF' },
|
||
{ v: 'weak', d: 'WEAK' },
|
||
{ v: 'strong', d: 'STRONG' },
|
||
];
|
||
const DR_OPTIONS = [
|
||
{ v: 'auto', d: 'AUTO' },
|
||
{ v: '100', d: 'DR100' },
|
||
{ v: '200', d: 'DR200' },
|
||
{ v: '400', d: 'DR400' },
|
||
];
|
||
const WM_COLORS = [
|
||
{ v: '#ffffff', d: 'WHITE' },
|
||
{ v: '#f59e0b', d: 'AMBER' },
|
||
{ v: '#000000', d: 'BLACK' },
|
||
];
|
||
const WM_SIZES = [
|
||
{ v: '1', d: '1x' },
|
||
{ v: '1.5', d: '1.5x' },
|
||
{ v: '2', d: '2x' },
|
||
];
|
||
// FONT's list: the faces the bundle carries (see engine/assets.ts). The phone
|
||
// lists the device's own families through the platform font manager; a browser
|
||
// has no font service, so the honest list is what we ship.
|
||
const WM_FONTS = [
|
||
{ v: 'DEFAULT', d: 'DEFAULT' },
|
||
...MARK_FACES.map((f) => ({ v: f.family, d: f.family.toUpperCase() })),
|
||
];
|
||
|
||
interface LoadedSource {
|
||
bytes: Uint8Array;
|
||
name: string;
|
||
url: string;
|
||
}
|
||
|
||
// The custom watermark's own geometry. x/y are fractions of the stamp the
|
||
// engine draws on (x = left edge of the text, y = its baseline), size is the
|
||
// multiplier of the base face — 3.2% of the stamp width. The drag box on the
|
||
// photo reads and writes exactly these three numbers, so what the box encloses
|
||
// is what the file gets.
|
||
type MarkStyle = { color: string; size: number; x: number; y: number; font: string | null };
|
||
const DEFAULT_MARK_STYLE: MarkStyle = { color: '#ffffff', size: 1, x: 0.5, y: 0.92, font: null };
|
||
|
||
// GPS carries the same three geometry numbers plus its two switches, and its
|
||
// box on the photo reads and writes them the same way the custom mark's does.
|
||
type GpsStyle = { color: string; showName: boolean; showTime: boolean; size: number; x: number; y: number; font: string | null };
|
||
const DEFAULT_GPS_STYLE: GpsStyle = { color: '#f59e0b', showName: true, showTime: true, size: 1.2, x: 0.06, y: 0.7, font: null };
|
||
|
||
// Where each mark's first row sits under y, and the icon column GPS prints
|
||
// before its text — the engine's own em values, mirrored from the box that
|
||
// measures them (see ImageStage's WM_* constants).
|
||
const CUSTOM_BASELINE_EM = 0.35;
|
||
const GPS_BASELINE_EM = 1.1;
|
||
const GPS_GAP_EM = 1.6;
|
||
|
||
// Everything a single UNDO step puts back. The recipe alone was not enough:
|
||
// frame, crop, rotation, marks and geotag all change the photo too.
|
||
interface Look {
|
||
recipe: Recipe;
|
||
simId: string | null;
|
||
frameId: FrameId;
|
||
crop: CropRatio;
|
||
cropRect: CropRect;
|
||
cropApplied: boolean;
|
||
rotation: 0 | 90 | 180 | 270;
|
||
straighten: number;
|
||
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>(() => ({
|
||
...DEFAULT_RECIPES[0],
|
||
id: 'web-current',
|
||
name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA',
|
||
...session.recipe,
|
||
}));
|
||
const [simId, setSimId] = useState<string | null>(() => session.simId ?? null);
|
||
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
|
||
const [crop, setCrop] = useState<CropRatio>(() => session.crop ?? 'none');
|
||
const [cropRect, setCropRect] = useState<CropRect>(() => session.cropRect ?? DEFAULT_CROP_RECT);
|
||
const [cropApplied, setCropApplied] = useState(() => session.cropApplied ?? false);
|
||
const [photoDims, setPhotoDims] = useState<{ w: number; h: number } | null>(null);
|
||
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0);
|
||
// FRAME's STRAIGHTEN: a fine angle on top of the quarter turn. Same pixels,
|
||
// so the photo's own dimensions never change and the crop stays valid.
|
||
const [straighten, setStraighten] = useState(() => session.straighten ?? 0);
|
||
// 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 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);
|
||
// PRO is the old "signed in", and then some: the account has to have proven
|
||
// the address it signed up with. Until it does, this build serves it exactly
|
||
// like a guest — watermarked 2048px export, no saving, no PRO tools. An
|
||
// allowlisted admin is trusted by construction and counts as verified.
|
||
const pro = !!user?.verified;
|
||
// Admins are the server's own operators: the API skips MAX_PHOTOS_PER_USER
|
||
// for them (server.ts), so the studio stops counting at the same place. null
|
||
// means "no cap" all the way down to SAVE PHOTO's label.
|
||
const photoCap = user?.admin ? null : MAX_PHOTOS;
|
||
// What the stamp's name line prints: the visitor's own PLACE NAME wins over
|
||
// whatever the fix carried, because neither a phone's position nor a photo's
|
||
// EXIF ever brings one, and typing it after locating must still show up.
|
||
// Memoised: this object is a dependency of the preview and the export, and a
|
||
// fresh one on every render would re-arm the render effect with its own
|
||
// output — the stage would repaint itself forever and the panel would stop
|
||
// answering clicks.
|
||
// 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);
|
||
// 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 unverified account to the
|
||
// same dialog for its letter.
|
||
const promptPro = useCallback(() => setAuthMode(user ? 'verify' : 'login'), [user]);
|
||
|
||
// The banner's own button. The address already has one live link, so this
|
||
// replaces it — the API caps how often, and answers either way.
|
||
const [resent, setResent] = useState(false);
|
||
const resendVerification = useCallback(async () => {
|
||
try {
|
||
await api.resendVerification();
|
||
setResent(true);
|
||
} catch (err) {
|
||
setError(t('err.generic', { msg: String(err) }));
|
||
}
|
||
}, [t]);
|
||
|
||
const renderToken = useRef(0);
|
||
// Which previewMax the bytes in previewBytes were made at, so the upgrade
|
||
// effect does not redo the load-time 1600px copy.
|
||
const previewMaxRef = useRef(PREVIEW_MAX);
|
||
const previewJobRef = useRef(0);
|
||
|
||
// --- boot -----------------------------------------------------------------
|
||
useEffect(() => {
|
||
let alive = true;
|
||
loadEngine()
|
||
.then((loaded) => {
|
||
if (!alive) return;
|
||
setEngine(loaded);
|
||
if (!loaded.gpu) setError(t('err.noWebgl'));
|
||
})
|
||
.catch((err) => alive && setError(String(err)));
|
||
api
|
||
.me()
|
||
.then((r) => alive && setUser(r.user))
|
||
.catch(() => undefined); // signed out is a valid state — the demo needs no account
|
||
// ...and the photo half of the session, back out of IndexedDB.
|
||
loadPhoto()
|
||
.then((photo) => {
|
||
if (!alive || !photo) return;
|
||
return adoptPhoto(
|
||
photo.bytes,
|
||
photo.name,
|
||
new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }),
|
||
true
|
||
);
|
||
})
|
||
.catch(() => undefined);
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// PRO, not merely signed in: those two listings are the API's own, and it
|
||
// answers an unverified account with a 403 (see requirePro there).
|
||
if (!pro) {
|
||
setSaved([]);
|
||
setMyPhotos([]);
|
||
return;
|
||
}
|
||
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
|
||
api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([]));
|
||
}, [pro]);
|
||
|
||
// --- preview render -------------------------------------------------------
|
||
// A new photo has nothing to compare yet: the toggle goes off with it, so the
|
||
// compare copy is never a stale render of the last one.
|
||
useEffect(() => {
|
||
setComparing(false);
|
||
}, [source]);
|
||
|
||
// 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]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!engine || !previewBytes) return;
|
||
const token = ++renderToken.current;
|
||
const timer = setTimeout(async () => {
|
||
setBusy(t('act.exporting'));
|
||
try {
|
||
// The geometry is the frame's own; only the recipe differs between the
|
||
// render and the compare copy, so the two can never drift apart.
|
||
const frame = cropEditing ? 'none' : frameId;
|
||
const options = stageOptions;
|
||
const out = await renderPhoto({
|
||
source: previewBytes,
|
||
recipe,
|
||
// An uncommitted crop is framed on the bare photo, so the overlay
|
||
// measures exactly what the engine will cut.
|
||
frameId: frame,
|
||
useGeotag,
|
||
gpsInfo,
|
||
assets: engine.assets,
|
||
options,
|
||
});
|
||
if (token !== renderToken.current) return;
|
||
if (!out) throw new Error('render returned null');
|
||
setPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' }));
|
||
});
|
||
if (!comparing) {
|
||
setCompareUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return null;
|
||
});
|
||
return;
|
||
}
|
||
const before = await renderPhoto({
|
||
source: previewBytes,
|
||
recipe: BASE_RECIPE,
|
||
frameId: frame,
|
||
useGeotag,
|
||
gpsInfo,
|
||
assets: engine.assets,
|
||
options,
|
||
});
|
||
if (token !== renderToken.current || !before) return;
|
||
setCompareUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return URL.createObjectURL(new Blob([before.bytes as BlobPart], { type: 'image/jpeg' }));
|
||
});
|
||
} catch (err) {
|
||
if (token === renderToken.current) setError(t('err.generic', { msg: String(err) }));
|
||
} finally {
|
||
if (token === renderToken.current) setBusy(null);
|
||
}
|
||
}, PREVIEW_DEBOUNCE_MS);
|
||
return () => clearTimeout(timer);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing]);
|
||
|
||
// The source copy behind previewBytes, re-made only when previewMax moves:
|
||
// applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back.
|
||
useEffect(() => {
|
||
if (!source || previewMaxRef.current === previewMax) return;
|
||
const job = ++previewJobRef.current;
|
||
resizedJpeg(source.bytes, previewMax)
|
||
.then((bytes) => {
|
||
if (job !== previewJobRef.current) return;
|
||
previewMaxRef.current = previewMax;
|
||
setPreviewBytes(bytes);
|
||
})
|
||
.catch(() => undefined);
|
||
}, [source, previewMax]);
|
||
|
||
// --- input ---------------------------------------------------------------
|
||
// One photo, two ways in: a drop/pick (which also stores it for the next
|
||
// visit) and the session restore, whose params are already in state and so
|
||
// must not be overwritten by the photo's own EXIF.
|
||
// A frame arriving on the stage replaces whatever the preview was holding.
|
||
// The old render is thrown away FIRST, so the new one can never be painted
|
||
// over it: the buffer is empty before the load starts.
|
||
const clearPreview = useCallback(() => {
|
||
setPreviewUrl((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev);
|
||
return null;
|
||
});
|
||
}, []);
|
||
|
||
// CLEAR: the photo leaves the stage, the look stays. The knobs, the sim, the
|
||
// frame and the stamps are the user's work and were made on this picture, not
|
||
// by it — so they survive it, exactly as they survive opening another photo.
|
||
// What goes is what the picture itself brought: its pixels, its shape, its
|
||
// EXIF position and ISO. The session's own copy goes too (forgetPhoto), or
|
||
// the next reload would put the frame straight back.
|
||
const clearPhoto = useCallback(() => {
|
||
setSource((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev.url);
|
||
return null;
|
||
});
|
||
clearPreview();
|
||
setPreviewBytes(null);
|
||
setPhotoDims(null);
|
||
setOpenPhotoId(null);
|
||
setGps(null);
|
||
setGpsManual({ lat: '', lng: '' });
|
||
setUseGeotag(false);
|
||
setIso(null);
|
||
setError(null);
|
||
setBusy(null);
|
||
void forgetPhoto();
|
||
}, [clearPreview]);
|
||
|
||
// What the phone app gets from the OS geocoder, the browser has no twin of,
|
||
// so the name behind a coordinate comes from the API. Fire and forget: the
|
||
// stamp is already readable without it, and a name that arrives late (or not
|
||
// at all) must never hold up the picture.
|
||
const resolvePlace = useCallback((latitude: number, longitude: number) => {
|
||
void api
|
||
.place(latitude, longitude)
|
||
.then(({ place: found }) => {
|
||
if (!found) return;
|
||
// Only the fix still on screen is worth naming — the user may have
|
||
// moved on to another photo while the lookup was in flight.
|
||
setGps((prev) => (prev && prev.latitude === latitude && prev.longitude === longitude ? { ...prev, locality: found } : prev));
|
||
})
|
||
.catch(() => undefined);
|
||
}, []);
|
||
|
||
// Every position that reaches the stage still without a name is named here,
|
||
// once: a photo restored from the session lands at boot, before /me has
|
||
// answered, so the account reading that put it there saw `pro` as false — and
|
||
// a guest's photo earns its name the moment they sign in. PRO only, and a
|
||
// position the user has named by hand is left alone.
|
||
useEffect(() => {
|
||
if (pro && gps && !gps.locality) resolvePlace(gps.latitude, gps.longitude);
|
||
}, [pro, gps, resolvePlace]);
|
||
|
||
// iPhone Safari returns a frame shot through the in-page camera with no EXIF
|
||
// at all (WebKit bug 207088), so the device's own position is the only
|
||
// automatic source left. Tried the moment such a photo lands (see adoptPhoto)
|
||
// and again on the panel's own button, for a permission that was refused or
|
||
// dismissed the first time. Secure origin only, which the check above gates.
|
||
// The caller may know the photo's own date and this callback not: the automatic
|
||
// try below happens inside adoptPhoto, whose render still holds the previous
|
||
// photo's date in state. So the date is handed in when it is known, and only a
|
||
// photo that carries none falls back to the clock.
|
||
const locateMe = useCallback((when?: number | null) => {
|
||
if (!navigator.geolocation) {
|
||
setError(t('wm.locFail'));
|
||
return;
|
||
}
|
||
setLocating(true);
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
const { latitude, longitude } = pos.coords;
|
||
setGpsManual({ lat: latitude.toFixed(6), lng: longitude.toFixed(6) });
|
||
setGps({ latitude, longitude, locality: place, timestamp: when ?? capturedAt ?? Date.now(), source: 'device' });
|
||
setUseGeotag(true);
|
||
setLocating(false);
|
||
},
|
||
() => {
|
||
setLocating(false);
|
||
locDeniedRef.current = true;
|
||
setError(t('wm.locFail'));
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
|
||
);
|
||
}, [place, t, capturedAt]);
|
||
|
||
const adoptPhoto = useCallback(
|
||
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
|
||
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
||
const found = await readGps(bytes);
|
||
const foundIso = await readIso(bytes);
|
||
// The photo's own date, held apart from its GPS: a frame with a date but no
|
||
// position still stamps the date when the position is typed in or asked of
|
||
// the device (see the two places the clock is fallen back on, below).
|
||
const taken = await readCapturedAt(bytes);
|
||
setCapturedAt(taken);
|
||
// The photo's own shape drives the default crop frame, and its own pixels
|
||
// are what an export is measured against — a 1600px copy would make a
|
||
// 4000px photo look like it needed inventing pixels it already has. Read
|
||
// off the original, not the preview.
|
||
const dims = await createImageBitmap(new Blob([bytes as BlobPart]))
|
||
.then((bmp) => {
|
||
const d = { w: bmp.width, h: bmp.height };
|
||
bmp.close();
|
||
return d;
|
||
})
|
||
.catch(() => null);
|
||
setPhotoDims(dims);
|
||
setSource((prev) => {
|
||
if (prev) URL.revokeObjectURL(prev.url);
|
||
return { bytes, name, url: URL.createObjectURL(blob) };
|
||
});
|
||
// The new photo's copy is the 1600px one; a live crop or a zoomed stage
|
||
// then upgrades it.
|
||
previewMaxRef.current = PREVIEW_MAX;
|
||
setZoomNeedPx(PREVIEW_MAX);
|
||
previewJobRef.current++;
|
||
setPreviewBytes(preview);
|
||
setGps(found);
|
||
setIso(foundIso);
|
||
// A new photo starts a new history whose floor is the default look, so
|
||
// UNDO walks all the way back and still leaves the photo loaded. REDO has
|
||
// nothing to walk forward onto in a history that just began.
|
||
setUndo([defaultLook()]);
|
||
setRedo([]);
|
||
if (keepGeo) return;
|
||
setUseGeotag(!!found);
|
||
if (!found) setGpsManual({ lat: '', lng: '' });
|
||
// A frame the phone handed over without EXIF gets one automatic try at the
|
||
// device's position, so the GPS stamp is there without a second tap. PRO
|
||
// only: the stamp is a PRO tool, and a guest's photo is not worth a
|
||
// location prompt.
|
||
if (!found && pro && canLocate && !locDeniedRef.current) locateMe(taken);
|
||
},
|
||
[defaultLook, pro, locateMe]
|
||
);
|
||
|
||
// 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);
|
||
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.
|
||
setOpenPhotoId(null);
|
||
try {
|
||
const bytes = await fileToBytes(file);
|
||
await adoptPhoto(bytes, file.name, file);
|
||
void savePhoto(file.name, bytes);
|
||
} catch {
|
||
setError(t('err.decode'));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
},
|
||
[t, adoptPhoto]
|
||
);
|
||
|
||
useEffect(() => {
|
||
const onPaste = (e: ClipboardEvent) => {
|
||
const file = e.clipboardData?.files?.[0];
|
||
if (file) void loadFile(file);
|
||
};
|
||
window.addEventListener('paste', onPaste);
|
||
return () => window.removeEventListener('paste', onPaste);
|
||
}, [loadFile]);
|
||
|
||
// --- recipe edits ---------------------------------------------------------
|
||
const applyRecipe = useCallback(
|
||
(next: Recipe) => {
|
||
remember();
|
||
setRecipe(next);
|
||
setSimId(next.isCustom ? null : next.id);
|
||
// A recipe filed by SAVE RECENT carries the frame it was saved with;
|
||
// a bundled preset carries none, and then the frame on the stage stays.
|
||
const stored = (next as Recipe & { frameId?: FrameId }).frameId;
|
||
if (stored) setFrameId(stored);
|
||
},
|
||
[remember]
|
||
);
|
||
|
||
const setAdjustment = useCallback(
|
||
(patch: Partial<Recipe['adjustments']>) => {
|
||
setRecipe((prev) => ({ ...prev, isCustom: true, adjustments: { ...prev.adjustments, ...patch } }));
|
||
},
|
||
[]
|
||
);
|
||
|
||
// A slider drag is one edit, not one per pixel: the first move after the
|
||
// ruler opened takes the snapshot, the rest ride on it.
|
||
const sliderEditRef = useRef(false);
|
||
useEffect(() => {
|
||
sliderEditRef.current = false;
|
||
}, [openParam]);
|
||
// The watermark's drag box rides the same one-edit-per-gesture rule as a knob:
|
||
// a whole drag is one undo step, not one per pointer move. The ref clears
|
||
// whenever the FRAME panel changes, which is when the box leaves the photo.
|
||
const wmEditRef = useRef(false);
|
||
// The colour wells drag the same way: one undo step per visit to the picker,
|
||
// not one per shade the slider under it passes through.
|
||
const wmColorRef = useRef(false);
|
||
useEffect(() => {
|
||
wmEditRef.current = false;
|
||
wmColorRef.current = false;
|
||
}, [openGroup]);
|
||
const setAdjustmentOnce = useCallback(
|
||
(patch: Partial<Recipe['adjustments']>) => {
|
||
if (!sliderEditRef.current) {
|
||
sliderEditRef.current = true;
|
||
remember();
|
||
}
|
||
setAdjustment(patch);
|
||
},
|
||
[remember, setAdjustment]
|
||
);
|
||
|
||
// FX's HEAL brush writes the spots the same way a knob writes its value: one
|
||
// gesture is one undo step whatever it laid down, so UNDO takes the dust back
|
||
// out a stroke at a time, and CLEAR — one step — hands the whole photo back.
|
||
// Nothing is capped: the shader is built to carry exactly the spots the
|
||
// recipe holds (shared/utils/heal.ts), so the repair made first is still
|
||
// there after the hundredth.
|
||
const addHealSpots = useCallback(
|
||
(spots: HealSpot[]) => {
|
||
if (!spots.length) return;
|
||
remember();
|
||
const list = lookRef.current?.recipe.adjustments.heal ?? [];
|
||
setAdjustment({ heal: [...list, ...spots] });
|
||
},
|
||
[remember, setAdjustment]
|
||
);
|
||
const clearHealSpots = useCallback(() => {
|
||
remember();
|
||
// The field is what "this photo was healed" means, so CLEAR drops it rather
|
||
// than leaving an empty list behind.
|
||
setAdjustment({ heal: undefined });
|
||
}, [remember, setAdjustment]);
|
||
|
||
// FX's MOSAIC brush writes its spots the same way, one gesture to one step.
|
||
// Nothing is capped here either (shared/utils/mosaic.ts), and every spot is
|
||
// laid down: unlike a repair, a hidden patch cannot be refused — there is no
|
||
// wrong light to borrow from, only cells.
|
||
const addMosaicSpots = useCallback(
|
||
(spots: MosaicSpot[]) => {
|
||
if (!spots.length) return;
|
||
remember();
|
||
const list = lookRef.current?.recipe.adjustments.mosaic ?? [];
|
||
setAdjustment({ mosaic: [...list, ...spots] });
|
||
},
|
||
[remember, setAdjustment]
|
||
);
|
||
const clearMosaicSpots = useCallback(() => {
|
||
remember();
|
||
// Same as HEAL's CLEAR: dropping the field is what "nothing is hidden"
|
||
// means, so no empty list is left behind.
|
||
setAdjustment({ mosaic: undefined });
|
||
}, [remember, setAdjustment]);
|
||
|
||
// Whichever tool is armed owns the pointer, its own size and its own list —
|
||
// one callback pair for the stage, chosen here, so the layer below never has
|
||
// to know which tool it is painting for.
|
||
const brushSpots: (HealSpot | MosaicSpot)[] = brushTool === 'mosaic' ? mosaicSpots : healSpots;
|
||
const brushR = brushTool === 'mosaic' ? mosaicR : healR;
|
||
const setBrushR = brushTool === 'mosaic' ? setMosaicR : setHealR;
|
||
const addBrushSpots = useCallback(
|
||
(spots: (HealSpot | MosaicSpot)[]) => {
|
||
if (brushTool === 'mosaic') addMosaicSpots(spots as MosaicSpot[]);
|
||
else addHealSpots(spots as HealSpot[]);
|
||
},
|
||
[brushTool, addMosaicSpots, addHealSpots]
|
||
);
|
||
|
||
// Moving or deleting a repair already on the photo is a knob being turned
|
||
// rather than a stroke being laid: the stage hands back the list the photo now
|
||
// holds, and says whether this change is the one the gesture started with —
|
||
// that first one is remembered, so a drag is a single undo step however many
|
||
// moves it took, and a press that only chose a spot records nothing at all. An
|
||
// empty list drops the field, the same way CLEAR does.
|
||
const editBrushSpots = useCallback(
|
||
(spots: (HealSpot | MosaicSpot)[], undo: boolean) => {
|
||
if (undo) remember();
|
||
const list = spots.length ? spots : undefined;
|
||
if (brushTool === 'mosaic') setAdjustment({ mosaic: list as MosaicSpot[] | undefined });
|
||
else setAdjustment({ heal: list as HealSpot[] | undefined });
|
||
},
|
||
[brushTool, remember, setAdjustment]
|
||
);
|
||
|
||
// FX's two gradient masks are a list the user draws rather than a knob, but
|
||
// they are written the same way the brush's spots are: a shape dragged onto
|
||
// the photo is one undo step, and the knobs on the chosen shape are one more
|
||
// however far they travel — the first move of a drag takes the snapshot and
|
||
// the rest ride on it. An empty list drops the field, the way CLEAR does.
|
||
const editMasks = useCallback(
|
||
(list: GradientMask[], undo: boolean) => {
|
||
if (undo) remember();
|
||
setAdjustment({ masks: list.length ? list : undefined });
|
||
setMaskSel(list.length ? Math.min(maskSel ?? 0, list.length - 1) : null);
|
||
},
|
||
[maskSel, remember, setAdjustment]
|
||
);
|
||
const addMask = useCallback(
|
||
(mask: GradientMask) => {
|
||
remember();
|
||
const list = lookRef.current?.recipe.adjustments.masks ?? [];
|
||
setAdjustment({ masks: [...list, mask] });
|
||
// The shape just drawn is the one being edited: its handles and its knobs
|
||
// are how the drag that made it is finished.
|
||
setMaskSel(list.length);
|
||
},
|
||
[remember, setAdjustment]
|
||
);
|
||
const deleteMask = useCallback(() => {
|
||
if (maskSel === null) return;
|
||
remember();
|
||
const list = masks.filter((_, i) => i !== maskSel);
|
||
setAdjustment({ masks: list.length ? list : undefined });
|
||
setMaskSel(null);
|
||
}, [masks, maskSel, remember, setAdjustment]);
|
||
// 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 (!pro) {
|
||
promptPro();
|
||
return;
|
||
}
|
||
setNaming('recipe');
|
||
}, [pro, promptPro]);
|
||
|
||
// A recipe name is only free while this account has not spent it. Checked on
|
||
// the field rather than at the store: the list is already here, whole.
|
||
const recipeNameTaken = useCallback(
|
||
(name: string): string | null =>
|
||
saved.some((entry) => entry.name.trim().toLowerCase() === name.toLowerCase()) ? t('name.duplicate') : null,
|
||
[saved, t]
|
||
);
|
||
|
||
const deleteRecipeEntry = useCallback(
|
||
async (entry: SavedRecipe) => {
|
||
try {
|
||
await api.deleteRecipe(entry.id);
|
||
setSaved((list) => list.filter((r) => r.id !== entry.id));
|
||
} catch (err) {
|
||
setError(t('err.generic', { msg: String(err) }));
|
||
}
|
||
},
|
||
[t]
|
||
);
|
||
|
||
// FAVORITED holds the account's own recipes. `*`, or dragging a recipe chip
|
||
// onto the star in the rail, files the look on the stage there; a guest is
|
||
// asked to sign in first, like every other write in the studio.
|
||
const favoriteRecipe = useCallback(
|
||
async (entry: Recipe) => {
|
||
if (!pro) {
|
||
promptPro();
|
||
return;
|
||
}
|
||
try {
|
||
const stored = await api.createRecipe(entry.name, { ...entry });
|
||
setSaved((list) => [stored.recipe, ...list]);
|
||
setTab('favorited');
|
||
} catch (err) {
|
||
setError(t('err.generic', { msg: String(err) }));
|
||
}
|
||
},
|
||
[pro, promptPro, t]
|
||
);
|
||
|
||
// The star reads the id the dragged chip carried back to its recipe.
|
||
const favoriteById = useCallback(
|
||
(id: string) => {
|
||
const entry = [...created, ...DEFAULT_RECIPES].find((r) => r.id === id);
|
||
if (entry) void favoriteRecipe(entry);
|
||
},
|
||
[created, favoriteRecipe]
|
||
);
|
||
|
||
// `*` files the look on the stage without leaving the tab it was built in.
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key !== '*' || e.ctrlKey || e.metaKey || e.altKey) return;
|
||
const el = e.target as HTMLElement | null;
|
||
if (el && (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable)) return;
|
||
e.preventDefault();
|
||
void favoriteRecipe(recipe);
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [favoriteRecipe, recipe]);
|
||
|
||
// CREATE tab: a new recipe is applied at once and listed under RECIPES. A
|
||
// PRO account also stores it; a guest's — or an unverified account's — copy
|
||
// stays in `created`, which is exactly what "forgotten when you leave" means.
|
||
const addCreatedRecipe = useCallback(
|
||
async (draft: RecipeDraft) => {
|
||
const entry: Recipe = { ...draft, id: `local-${Date.now()}`, isCustom: true };
|
||
setCreated((list) => [entry, ...list]);
|
||
applyRecipe(entry);
|
||
if (!pro) return;
|
||
try {
|
||
const stored = await api.createRecipe(entry.name, entry);
|
||
setSaved((list) => [stored.recipe, ...list]);
|
||
} catch (err) {
|
||
setError(t('err.generic', { msg: String(err) }));
|
||
}
|
||
},
|
||
[pro, applyRecipe, t]
|
||
);
|
||
|
||
// CREATE edits the photo live: each knob lands on the render as it is typed so
|
||
// the recipe can be judged before saving. No undo entry — this is a draft, and
|
||
// SAVE/EXPORT is what commits it.
|
||
const previewCreatedRecipe = useCallback((draft: Pick<Recipe, 'baseFilter' | 'adjustments'>) => {
|
||
setRecipe((prev) => ({ ...prev, ...draft, isCustom: true }));
|
||
}, []);
|
||
|
||
// The phone's encrypted .recipe envelope (`shared/utils/recipeShare` is the
|
||
// app's own file, byte-identical). PRO only — a guest's recipe, or an
|
||
// unverified account's, is never written to disk.
|
||
const exportRecipeFile = useCallback(
|
||
(draft: RecipeDraft) => {
|
||
if (!pro) {
|
||
promptPro();
|
||
return;
|
||
}
|
||
const xml = exportRecipeXml({ ...draft, id: 'share' });
|
||
download(
|
||
new TextEncoder().encode(xml),
|
||
`${(draft.name || 'recipe').replace(/\W+/g, '-').toLowerCase()}.recipe`,
|
||
'application/xml'
|
||
);
|
||
},
|
||
[pro, promptPro]
|
||
);
|
||
|
||
// IMPORT is the other half of EXPORT: the phone's .recipe file, or one saved
|
||
// from another account. `shared/utils/recipeShare` is the app's own parser, so
|
||
// the look arrives intact — a guest gets it for the session, like CREATE.
|
||
const importRecipeFile = useCallback(
|
||
async (file: File) => {
|
||
setError(null);
|
||
try {
|
||
const parsed = importRecipeXml(await file.text());
|
||
const entry: Recipe = { ...parsed, id: `import-${Date.now()}`, isCustom: true };
|
||
setCreated((list) => [entry, ...list]);
|
||
applyRecipe(entry);
|
||
setFrameId(parsed.frameId ?? 'none');
|
||
setUseGeotag(!!parsed.useGeotag);
|
||
setShowRecipes(true);
|
||
} catch (err) {
|
||
setError(t('err.generic', { msg: String(err) }));
|
||
}
|
||
},
|
||
[applyRecipe, t]
|
||
);
|
||
|
||
// The picker is built on the spot: a second <input type=file> in the page
|
||
// would compete with the stage's own for `input[type=file]`.
|
||
const pickRecipeFile = useCallback(() => {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.recipe,application/xml,text/xml';
|
||
input.onchange = () => {
|
||
const file = input.files?.[0];
|
||
if (file) void importRecipeFile(file);
|
||
};
|
||
input.click();
|
||
}, [importRecipeFile]);
|
||
|
||
// The strip this look shows — the tagline off the recipe name, and the
|
||
// technical line off the camera (EXIF ISO, or AUTO) plus the recipe's own
|
||
// grain, warmth and monochrome state. Metadata only: an exported file keeps
|
||
// clean pixels (the strip rides EXIF), and a saved photo stores it so the
|
||
// landing reel can frame it the same way.
|
||
const strip = useMemo(() => {
|
||
const name = (recipe.name || 'RECIPE').trim();
|
||
const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase();
|
||
// The amount is on half steps now, so the tag prints the half it was set to
|
||
// instead of rounding a 3.5 to a 4 the frame does not carry.
|
||
const grainValue = Math.max(0, recipe.adjustments.grain ?? 0);
|
||
const grain = Number.isInteger(grainValue) ? String(grainValue) : grainValue.toFixed(1);
|
||
const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250);
|
||
const tail = isMonochromeBase(recipe.baseFilter) ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`;
|
||
return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` };
|
||
}, [recipe.name, recipe.adjustments, recipe.baseFilter, iso]);
|
||
|
||
// Everything needed to put this photo back on the stage: the recipe plus the
|
||
// frame and geotag the stage held. EXIF and a saved photo both carry it.
|
||
const photoSettings = useMemo(
|
||
() => ({ ...recipe, frameId: cropEditing ? 'none' : frameId, useGeotag }),
|
||
[recipe, frameId, cropEditing, useGeotag]
|
||
);
|
||
|
||
// The look the stage is holding, judged against the PRO list. The chips keep
|
||
// a guest from picking one, but a look can arrive without them: an imported
|
||
// .recipe, or a photo saved before the gate existed. The FILE is where the
|
||
// gate bites — the preview is free, exactly as it is on the phone.
|
||
const proLookInUse = useCallback(
|
||
() =>
|
||
PRO_FRAMES.includes(frameId) ||
|
||
PRO_SIMS.includes(simId ?? '') ||
|
||
useGeotag ||
|
||
(recipe.adjustments.hdf ?? 0) > 0 ||
|
||
// The mixer, on either of its two axes: a band's HUE/SAT/LUM, or the
|
||
// image-wide three that sit under the IMAGE divider.
|
||
Object.keys(recipe.adjustments.hslBands ?? {}).length > 0 ||
|
||
(recipe.adjustments.hslHue ?? 0) !== 0 ||
|
||
(recipe.adjustments.hslSat ?? 0) !== 0 ||
|
||
(recipe.adjustments.hslLum ?? 0) !== 0,
|
||
[
|
||
frameId,
|
||
simId,
|
||
useGeotag,
|
||
recipe.adjustments.hdf,
|
||
recipe.adjustments.hslBands,
|
||
recipe.adjustments.hslHue,
|
||
recipe.adjustments.hslSat,
|
||
recipe.adjustments.hslLum,
|
||
]
|
||
);
|
||
|
||
// `true` when a file was handed over, so CLEAR knows the export it offered
|
||
// actually happened before the photo comes off the stage.
|
||
const exportPhoto = useCallback(async (target: number | null): Promise<boolean> => {
|
||
if (!engine || !source) return false;
|
||
if (!pro && proLookInUse()) {
|
||
promptPro();
|
||
return false;
|
||
}
|
||
setExporting(true);
|
||
setError(null);
|
||
try {
|
||
const tier = TIERS[pro ? 'user' : 'guest'];
|
||
// The menu's answer, under the tier's ceiling. UNCHANGED is `null`.
|
||
const wanted = target === null ? null : Math.min(target, tier.maxExportDim ?? target);
|
||
// How far the photo's own long edge must be stretched for the CROP to
|
||
// land on `wanted`: the render keeps only `editedLongest` of the photo.
|
||
const needLongest = wanted === null ? sourceLongest : Math.round((sourceLongest * wanted) / editedLongest);
|
||
let src = source.bytes;
|
||
if (wanted === null) {
|
||
// The tier's ceiling is the only thing that still resizes, and only
|
||
// downwards: this is the export the button did before it had a menu.
|
||
if (tier.maxExportDim && sourceLongest > tier.maxExportDim) {
|
||
src = await resizedJpeg(source.bytes, tier.maxExportDim, 0.95);
|
||
}
|
||
} else if (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`);
|
||
return true;
|
||
} catch (err) {
|
||
setError(t('err.generic', { msg: String(err) }));
|
||
return false;
|
||
} finally {
|
||
setExporting(false);
|
||
// The upscale's own progress line goes with the export that wrote it.
|
||
setBusy(null);
|
||
}
|
||
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, sourceLongest, editedLongest, useGeotag, gpsInfo, gpsStyle, rotation, straighten, flipH, flipV, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]);
|
||
|
||
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
|
||
// The upload is left clean (no burned strip) so the landing reel can draw its
|
||
// own `.lp-shot` frame around it; the labels are stored alongside instead.
|
||
// The 12-photo cap is the API's, mirrored here so the button can say no.
|
||
// `true` once the row and its base are stored, so CLEAR only takes the photo
|
||
// off the stage when the save it offered really landed.
|
||
const savePhotoToFolder = useCallback(async (photoName?: string): Promise<boolean> => {
|
||
if (!pro) {
|
||
promptPro();
|
||
return false;
|
||
}
|
||
if (!engine || !source) {
|
||
setError(t('save.photoNoSource'));
|
||
return false;
|
||
}
|
||
// Re-saving the frame already on the stage replaces its own row, so the cap
|
||
// only stands in the way of a new one.
|
||
const open = myPhotos.find((p) => p.id === openPhotoId);
|
||
if (!open && photoCap !== null && myPhotos.length >= photoCap) {
|
||
setError(t('save.photoFull', { max: photoCap }));
|
||
return false;
|
||
}
|
||
// A frame being filed for the first time is asked for a name — the folder
|
||
// keeps it, and the landing reel draws it as the frame's title. A re-save
|
||
// keeps the name it already has, so it never asks twice.
|
||
if (!open && photoName === undefined) {
|
||
setNaming('photo');
|
||
return false;
|
||
}
|
||
// A re-save keeps the name the frame already has — the folder's title, not
|
||
// the recipe it happens to be standing in.
|
||
const labels = { ...strip, title: open ? open.title ?? strip.title : (photoName ?? strip.title) };
|
||
setSavingPhoto(true);
|
||
setError(null);
|
||
try {
|
||
const src = TIERS.user.maxExportDim
|
||
? await resizedJpeg(source.bytes, TIERS.user.maxExportDim, 0.95)
|
||
: source.bytes;
|
||
const out = await renderPhoto({
|
||
source: src,
|
||
recipe,
|
||
frameId: cropEditing ? 'none' : frameId,
|
||
useGeotag,
|
||
gpsInfo,
|
||
assets: engine.assets,
|
||
options: {
|
||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||
dpi: 300,
|
||
sharpen: true,
|
||
photoRotation: rotation,
|
||
photoStraighten: straighten,
|
||
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: src,
|
||
recipe: BASE_RECIPE,
|
||
frameId: 'none',
|
||
assets: engine.assets,
|
||
options: {
|
||
dpi: 300,
|
||
// The live preview sharpens once on top of this, so the base is left
|
||
// unsharpened — the render the folder shows carries its own.
|
||
sharpen: false,
|
||
photoRotation: rotation,
|
||
photoStraighten: straighten,
|
||
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);
|
||
}
|
||
}, [pro, 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 < (TIERS[pro ? 'user' : 'guest'].maxExportDim ?? 4096)) preloadSuperRes();
|
||
setChoosingExport(true);
|
||
}, [engine, source, pro, proLookInUse, promptPro, editedLongest]);
|
||
|
||
// The original file, handed back byte for byte — the untouched frame the
|
||
// camera took, never the render. A page cannot write to the camera roll, so
|
||
// on a phone the file goes to the share sheet and "Save Image" on it is the
|
||
// tap that files it in the photo library; a browser with no file sharing (or
|
||
// a desktop) downloads it instead.
|
||
const saveOriginal = useCallback(async () => {
|
||
if (!source) return;
|
||
const ext = source.name.split('.').pop()?.toLowerCase() ?? '';
|
||
const type = (ORIGINAL_MIME as Record<string, string>)[ext] ?? 'image/jpeg';
|
||
const file = new File([source.bytes as BlobPart], source.name, { type });
|
||
try {
|
||
if (navigator.canShare?.({ files: [file] })) {
|
||
await navigator.share({ files: [file] });
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
// The sheet was closed: a "not now", not a failure, and no download on
|
||
// top of the choice the user just made.
|
||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||
}
|
||
download(source.bytes, source.name, type);
|
||
}, [source]);
|
||
|
||
// Reopen one of this account's saved frames: the bytes come back as a File
|
||
// through the same door an upload uses, then the stored look goes on top.
|
||
const openMyPhoto = useCallback(
|
||
async (photo: Photo) => {
|
||
setBusy(t('auth.busy'));
|
||
setError(null);
|
||
// The stage is emptied before the fetch, not after it: the frame being
|
||
// reopened must not land on top of the one already standing there.
|
||
clearPreview();
|
||
setHistPick(null);
|
||
try {
|
||
// The base is the pixels the look was applied to; `/file` is only the
|
||
// fallback, for a photo saved before its base was kept. Loading the
|
||
// render is what applied the look a second time — the doubled frame and
|
||
// the stacked grade.
|
||
const base = await fetch(api.photoBaseUrl(photo.id));
|
||
const res = base.ok ? base : await fetch(api.photoUrl(photo.id));
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const blob = await res.blob();
|
||
await loadFile(new File([blob], `${photo.id}.jpg`, { type: blob.type || 'image/jpeg' }));
|
||
const saved = photo.recipe as (Recipe & { frameId?: FrameId; useGeotag?: boolean }) | null;
|
||
if (saved && base.ok) {
|
||
applyRecipe(saved);
|
||
if (saved.frameId) setFrameId(saved.frameId);
|
||
setUseGeotag(!!saved.useGeotag);
|
||
} else {
|
||
// No base: those pixels already carry the look, so it stands as it
|
||
// is — the stored one is not laid over it again. The neutral stock
|
||
// leaves them untouched, and the stage starts from nothing.
|
||
restoreLook({ ...defaultLook(), recipe: BASE_RECIPE, simId: null, frameId: 'none' });
|
||
}
|
||
setOpenPhotoId(photo.id);
|
||
} catch {
|
||
setError(t('err.decode'));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
},
|
||
[loadFile, applyRecipe, restoreLook, defaultLook, clearPreview, t]
|
||
);
|
||
|
||
// The saved frame's own switch. The reel reads `consent`, so the tick answers
|
||
// the click at once; only the store's refusal (a failed call) takes it back.
|
||
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
|
||
if (!photo) return;
|
||
const next = photo.consent === false;
|
||
const mark = (value: boolean) =>
|
||
setMyPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, consent: value } : p)));
|
||
mark(next);
|
||
try {
|
||
await api.setPhotoConsent(photo.id, next);
|
||
} catch {
|
||
mark(!next);
|
||
}
|
||
}, []);
|
||
|
||
// Removing a frame takes its row and the looks its history kept. Confirmed
|
||
// first (ui/DeletePhotoModal), never on a single click.
|
||
const removeMyPhoto = useCallback(
|
||
async (photo: Photo) => {
|
||
try {
|
||
await api.deletePhoto(photo.id);
|
||
setMyPhotos((prev) => prev.filter((p) => p.id !== photo.id));
|
||
if (openPhotoId === photo.id) setOpenPhotoId(null);
|
||
} catch (err) {
|
||
setError(t('err.generic', { msg: err instanceof Error ? err.message : String(err) }));
|
||
} finally {
|
||
setRemoving(null);
|
||
}
|
||
},
|
||
[openPhotoId, t]
|
||
);
|
||
|
||
// --- strips ---------------------------------------------------------------
|
||
// The cascade: a chip opens the next column, and the column it came from stays
|
||
// put (TEMP -> COLOR TEMP leaves TEMP visible with the ruler beside it). The
|
||
// recipes list is the one exception — it belongs to PRESETS, not to a chip, so
|
||
// anything else opening closes it.
|
||
const toggleParam = useCallback(
|
||
(key: string) => {
|
||
const close = openParam === key;
|
||
setOpenParam(close ? null : key);
|
||
setShowRecipes(false);
|
||
// 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]);
|
||
|
||
// Continuous parameter chips: label + live value; amber text when edited,
|
||
// amber border while its ruler is open.
|
||
const paramChips = (defs: ContinuousParamDef[]): ChipDef[] =>
|
||
defs.map((def) => {
|
||
const value = def.get(recipe.adjustments);
|
||
const locked = !!def.pro && !pro;
|
||
return {
|
||
key: def.key,
|
||
label: def.label,
|
||
value: def.display(value),
|
||
active: openParam === def.key,
|
||
amberValue: openParam !== def.key && value !== def.defaultValue,
|
||
// The phone greys a PRO knob out; the web keeps it clickable and says
|
||
// why instead, because the fix here is an action (prove the address)
|
||
// rather than a purchase.
|
||
pro: !!def.pro,
|
||
onClick: locked ? promptPro : () => toggleParam(def.key),
|
||
};
|
||
});
|
||
|
||
const slider: SliderDef | null = useMemo(() => {
|
||
if (!openParam) return null;
|
||
// STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not
|
||
// stacked in this column at all. Its ruler lies across the foot of the
|
||
// photo (StraightenRuler, drawn by ImageStage) so the angle is read off the
|
||
// picture it is levelling; nothing opens here. TONE CURVE is the same shape
|
||
// of tool: its graph is on the photo, so it opens no row either.
|
||
if (openParam === 'straighten' || openParam === 'curve') return null;
|
||
// The mixer's three knobs are not in PARAM_DEFS: they read and write the
|
||
// image's own move rather than a flat field of adjustments, and unlike the
|
||
// panel on the photo they are not tied to the band the mixer is aimed at.
|
||
if (openParam.startsWith('hsl.')) {
|
||
const which = openParam === 'hsl.h' ? 0 : openParam === 'hsl.s' ? 1 : 2;
|
||
const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0];
|
||
const value = own[which];
|
||
const set = (v: number) => setHslGlobal(which as 0 | 1 | 2, v);
|
||
const name = openParam === 'hsl.h' ? 'HUE' : openParam === 'hsl.s' ? 'SAT' : 'LUM';
|
||
return {
|
||
key: openParam,
|
||
label: `${name} IMAGE`,
|
||
value,
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
display: value > 0 ? `+${value}` : String(value),
|
||
edited: value !== 0,
|
||
onChange: set,
|
||
onReset: () => set(0),
|
||
onBack: () => setOpenParam(null),
|
||
};
|
||
}
|
||
const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters];
|
||
const def = all.find((d) => d.key === openParam);
|
||
if (!def) return null;
|
||
const value = def.get(recipe.adjustments);
|
||
return {
|
||
key: def.key,
|
||
label: def.label,
|
||
value,
|
||
min: def.min,
|
||
max: def.max,
|
||
step: def.step,
|
||
display: def.display(value),
|
||
edited: value !== def.defaultValue,
|
||
swatch: SLIDER_SWATCH[def.key]?.(value),
|
||
onChange: (value) => setAdjustmentOnce(def.set(value)),
|
||
onReset: () => setAdjustmentOnce(def.set(def.defaultValue)),
|
||
// Back reopens the strip the row was launched from (COLOR TEMP -> TEMP).
|
||
onBack: () => {
|
||
const g = PARAM_GROUP[def.key];
|
||
setOpenParam(null);
|
||
if (g) setOpenGroup(g);
|
||
},
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [openParam, recipe.adjustments, setAdjustmentOnce, setHslGlobal, straighten, setStraightenOnce]);
|
||
|
||
// The eyedropper's report: print what was read, point the ruler at that
|
||
// colour's band, hang the mixer's panel on the spot it came from, and put the
|
||
// tool away — one pick is one colour.
|
||
const pickColor = useCallback((rgb: { r: number; g: number; b: number }, at: { fx: number; fy: number }) => {
|
||
setSample(rgb);
|
||
setHslBand(nearestHslBand(rgbToHsl(rgb.r, rgb.g, rgb.b).h));
|
||
setPickedAt(at);
|
||
setPicking(false);
|
||
}, []);
|
||
const picked = sample ? rgbToHsl(sample.r, sample.g, sample.b) : null;
|
||
const bandName = HSL_BANDS.find((b) => b.id === hslBand)?.label ?? '';
|
||
// The colour as this band's knobs leave it: the panel's swatch, and what the
|
||
// mixer is doing to every pixel the band owns.
|
||
const mixed = (() => {
|
||
if (!picked) return null;
|
||
const band = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
|
||
const h = picked.h + band[0];
|
||
const s = Math.max(0, Math.min(100, picked.s * 100 + band[1]));
|
||
const l = Math.max(0, Math.min(100, picked.l * 100 + band[2]));
|
||
return { hex: hslToHex(h, s, l) };
|
||
})();
|
||
const bandKnobs = recipe.adjustments.hslBands?.[hslBand] ?? ([0, 0, 0] as HslBand);
|
||
// The panel the stage hangs on the picked point: the colour on top (it moves
|
||
// with the knobs), then HUE/SAT/LUM in one row under it. Each takes the wheel
|
||
// for one step and a double-click back to 0.
|
||
const pickPanel =
|
||
sample && picked && mixed && pickedAt ? (
|
||
<>
|
||
<div className="hsl-card-head">
|
||
<span
|
||
className="hsl-card-swatch"
|
||
data-key="hsl-panel-swatch"
|
||
data-color={mixed.hex}
|
||
style={{ background: mixed.hex }}
|
||
/>
|
||
<span className="hsl-card-name" data-key="hsl-panel-name">
|
||
{bandName}
|
||
<b>{mixed.hex}</b>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="hsl-card-close"
|
||
data-key="hsl-panel-close"
|
||
aria-label="close"
|
||
onClick={() => setPickedAt(null)}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className="hsl-card-knobs">
|
||
<MiniSlider label="HUE" value={bandKnobs[0]} onChange={(v) => setBandKnob(0, v)} onReset={() => setBandKnob(0, 0)} />
|
||
<MiniSlider label="SAT" value={bandKnobs[1]} onChange={(v) => setBandKnob(1, v)} onReset={() => setBandKnob(1, 0)} />
|
||
<MiniSlider label="LUM" value={bandKnobs[2]} onChange={(v) => setBandKnob(2, v)} onReset={() => setBandKnob(2, 0)} />
|
||
</div>
|
||
</>
|
||
) : null;
|
||
|
||
// Option strips: a chip opens one, each option picks a value. `off` is the
|
||
// neutral value the chip glows amber against.
|
||
//
|
||
// TEMP's value: the preset the user last picked while the engine pair still
|
||
// matches it, otherwise the preset that pair maps to (neutral ⇒ AUTO),
|
||
// otherwise the bare kelvin — where COLOR TEMP dragged by hand lands. The
|
||
// phone's own wbValue(), so one pair lights one chip on both platforms.
|
||
const wbValue = (): string => {
|
||
const t = Math.round(recipe.adjustments.temperature);
|
||
const ti = recipe.adjustments.tint ?? 0;
|
||
const remembered = WB_PRESETS.find((w) => w.key === wbChoice);
|
||
if (remembered && remembered.kelvin === t && remembered.tint === ti) return remembered.key;
|
||
return WB_PRESETS.find((w) => w.kelvin === t && w.tint === ti)?.key ?? `${t}K`;
|
||
};
|
||
// TEMP is the one strip chip that names its value, and it names it always:
|
||
// TEMP AUTO at the neutral pair, TEMP SHADE on a preset, TEMP 6300K where a
|
||
// hand-dragged ruler landed.
|
||
const wbLabel = (): string => {
|
||
const v = wbValue();
|
||
return WB_PRESETS.find((w) => w.key === v)?.label ?? v;
|
||
};
|
||
// GRAIN's readout: the clump count the stock and the SIZE knob add up to, in
|
||
// the print's own terms (grainPerInch, grainShader.ts). A READOUT, not a pick —
|
||
// the strip prints it beside the two knobs that move it. The field swings a
|
||
// fifth of it patch by patch, which is what the knob is for, not an error in
|
||
// the number.
|
||
const grainInch = (): string =>
|
||
`${grainPerInch(grainStockFor(recipe.baseFilter), recipe.adjustments.grainSize ?? 100)}/INCH`;
|
||
const groupDefs: Record<Exclude<GroupKey, 'wm'>, {
|
||
label: string;
|
||
off: string;
|
||
value: string;
|
||
options: { v: string; d: string; pro?: boolean }[];
|
||
onPick: (v: string) => void;
|
||
}> = {
|
||
cx: {
|
||
label: 'COLOR CHROME', off: 'none', value: recipe.adjustments.colorChrome || 'none',
|
||
options: CHROME_OPTIONS,
|
||
onPick: (v) => {
|
||
remember();
|
||
setAdjustment({ colorChrome: v as Recipe['adjustments']['colorChrome'] });
|
||
},
|
||
},
|
||
cxb: {
|
||
label: 'CHROME BLUE', off: 'none', value: recipe.adjustments.colorChromeBlue || 'none',
|
||
options: CHROME_OPTIONS,
|
||
onPick: (v) => {
|
||
remember();
|
||
setAdjustment({ colorChromeBlue: v as Recipe['adjustments']['colorChromeBlue'] });
|
||
},
|
||
},
|
||
dr: {
|
||
label: 'D.RANGE', off: 'auto', value: String(recipe.adjustments.dynamicRange ?? 'auto'),
|
||
options: DR_OPTIONS,
|
||
onPick: (v) => {
|
||
remember();
|
||
setAdjustment({ dynamicRange: (v === 'auto' ? 'auto' : Number(v)) as Recipe['adjustments']['dynamicRange'] });
|
||
},
|
||
},
|
||
// Picking a ratio frames it on the photo (drag the corners, then APPLY) —
|
||
// nothing is cut until APPLY.
|
||
crop: {
|
||
label: 'CROP', off: 'none', value: crop,
|
||
options: CROPS.map((c) => ({ v: c, d: c === 'none' ? 'NONE' : c.toUpperCase() })),
|
||
onPick: (v) => pickCrop(v as CropRatio),
|
||
},
|
||
// RESET levels the whole frame; the quarter turns are what this strip picks,
|
||
// the fine angle is the STRAIGHTEN chip below it.
|
||
rotate: {
|
||
label: 'ROTATE', off: '0', value: String(rotation),
|
||
options: [
|
||
{ v: 'reset', d: 'RESET' },
|
||
{ v: '0', d: '0°' },
|
||
{ v: '90', d: '90°' },
|
||
{ v: '180', d: '180°' },
|
||
{ v: '270', d: '270°' },
|
||
],
|
||
onPick: (v) => {
|
||
remember();
|
||
setRotation(v === 'reset' ? 0 : (Number(v) as 0 | 90 | 180 | 270));
|
||
// RESET levels the whole frame, angle 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.
|
||
temp: {
|
||
label: 'TEMP', off: 'auto', value: wbValue(),
|
||
options: [
|
||
{ v: 'color-temp', d: 'COLOR TEMP' },
|
||
...WB_PRESETS.map((p) => ({ v: p.key, d: p.label })),
|
||
],
|
||
onPick: (v) => {
|
||
if (v === 'color-temp') return toggleParam('temperature');
|
||
remember();
|
||
// A preset is the LOOK, tint included: the phone's WB-tab chip sets the
|
||
// pair, and a chip that only moved the kelvin left the frame a different
|
||
// colour on the two platforms.
|
||
setWbChoice(v);
|
||
const p = WB_PRESETS.find((w) => w.key === v) ?? WB_PRESETS[0];
|
||
setAdjustment({ temperature: p.kelvin, tint: p.tint });
|
||
},
|
||
},
|
||
// MONOCHROME GRAIN: the amount and the size of the clumps, then the count
|
||
// they add up to. The two knobs and their readout share one strip because
|
||
// they are one subject; the chip above them carries the amount, the way TEMP
|
||
// carries the kelvin.
|
||
grain: {
|
||
label: 'MONOCHROME GRAIN', off: '0',
|
||
// While a ruler is open its own entry is what the chip names, the way
|
||
// ROTATE's STRAIGHTEN does on the phone; otherwise it carries the amount,
|
||
// which is the one the strip is about.
|
||
value:
|
||
openParam === 'grain' ? 'amount' : openParam === 'grainSize' ? 'size' : String(recipe.adjustments.grain),
|
||
options: [
|
||
{ v: 'amount', d: 'AMOUNT' },
|
||
{ v: 'size', d: 'SIZE' },
|
||
],
|
||
onPick: (v) => toggleParam(v === 'size' ? 'grainSize' : 'grain'),
|
||
},
|
||
wmColor: {
|
||
label: 'TEXT COLOR', off: '#ffffff', value: markStyle.color,
|
||
options: WM_COLORS,
|
||
onPick: (v) => {
|
||
remember();
|
||
setMarkStyle((prev) => ({ ...prev, color: v }));
|
||
},
|
||
},
|
||
wmSize: {
|
||
label: 'TEXT SIZE', off: '1', value: String(markStyle.size),
|
||
options: WM_SIZES,
|
||
onPick: (v) => {
|
||
remember();
|
||
setMarkStyle((prev) => ({ ...prev, size: Number(v) }));
|
||
},
|
||
},
|
||
// The two FONT chips: the custom mark's own face, and GPS's (the phone
|
||
// gives GPS a FONT strip too — it used to print in the bundled face only).
|
||
wmFont: {
|
||
label: 'TEXT FONT', off: 'DEFAULT', value: markStyle.font ?? 'DEFAULT',
|
||
options: WM_FONTS,
|
||
onPick: (v) => {
|
||
remember();
|
||
setMarkStyle((prev) => ({ ...prev, font: v === 'DEFAULT' ? null : v }));
|
||
},
|
||
},
|
||
gpsFont: {
|
||
label: 'FONT', off: 'DEFAULT', value: gpsStyle.font ?? 'DEFAULT',
|
||
options: WM_FONTS,
|
||
onPick: (v) => {
|
||
remember();
|
||
setGpsStyle((prev) => ({ ...prev, font: v === 'DEFAULT' ? null : v }));
|
||
},
|
||
},
|
||
gpsColor: {
|
||
label: 'COLOR', off: '#f59e0b', value: gpsStyle.color,
|
||
options: WM_COLORS,
|
||
onPick: (v) => {
|
||
remember();
|
||
setGpsStyle((prev) => ({ ...prev, color: v }));
|
||
},
|
||
},
|
||
gpsName: {
|
||
label: 'PLACE NAME', off: 'ON', value: gpsStyle.showName ? 'ON' : 'OFF',
|
||
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
|
||
onPick: (v) => {
|
||
remember();
|
||
setGpsStyle((prev) => ({ ...prev, showName: v === 'on' }));
|
||
},
|
||
},
|
||
gpsTime: {
|
||
label: 'TIME', off: 'ON', value: gpsStyle.showTime ? 'ON' : 'OFF',
|
||
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
|
||
onPick: (v) => {
|
||
remember();
|
||
setGpsStyle((prev) => ({ ...prev, showTime: v === 'on' }));
|
||
},
|
||
},
|
||
};
|
||
|
||
// A strip chip names its pick on the right edge, spelled the way the strip's
|
||
// own option spells it. Still on its neutral value -> nothing to name.
|
||
const groupChip = (g: Exclude<GroupKey, 'wm'>): ChipDef => {
|
||
const { label, value, off, options } = groupDefs[g];
|
||
const picked = options.find((o) => o.v === value);
|
||
return {
|
||
key: `grp-${g}`,
|
||
label,
|
||
value: value !== off ? picked?.d ?? value : undefined,
|
||
active: openGroup === g,
|
||
amberValue: openGroup !== g && value !== off,
|
||
onClick: () => toggleGroup(g),
|
||
};
|
||
};
|
||
|
||
const choiceChips = (
|
||
prefix: string,
|
||
options: { v: string; d: string; pro?: boolean }[],
|
||
current: string,
|
||
onPick: (v: string) => void
|
||
): ChipDef[] =>
|
||
options.map((o) => ({
|
||
key: `${prefix}:${o.v}`,
|
||
label: o.d,
|
||
active: current === o.v,
|
||
pro: o.pro,
|
||
onClick: () => onPick(o.v),
|
||
}));
|
||
|
||
const wmOn = useGeotag || markOn;
|
||
// An iPhone frame shot in the in-page camera carries no EXIF, so a photo with
|
||
// no GPS is no longer a dead end — the switch stays live wherever the browser
|
||
// can be asked for the device's own position (see locateMe).
|
||
const canLocate = typeof navigator !== 'undefined' && !!navigator.geolocation;
|
||
// Each watermark type is a collapse of its own: its chip is the header, and
|
||
// the header's panel (col 2) carries that mark's own controls under it — its
|
||
// colour, its face, its switches, its fields. Before, the two marks shared one
|
||
// column, so both sets of controls sat open at once. The mark being OFF is
|
||
// what closes the section, the same rule the phone's panel draws.
|
||
const gpsChips = (): ChipDef[] => [
|
||
{
|
||
key: 'gps',
|
||
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
|
||
active: useGeotag,
|
||
pro: true,
|
||
disabled: !gps && !(gpsManual.lat && gpsManual.lng) && !canLocate,
|
||
onClick: () => {
|
||
if (!pro) {
|
||
promptPro();
|
||
return;
|
||
}
|
||
remember();
|
||
setUseGeotag(!useGeotag);
|
||
},
|
||
},
|
||
...(useGeotag ? [groupChip('gpsColor'), groupChip('gpsFont'), groupChip('gpsName'), groupChip('gpsTime')] : []),
|
||
];
|
||
const customChips = (): ChipDef[] => [
|
||
{
|
||
key: 'custom-wm',
|
||
label: markOn ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF',
|
||
active: markOn,
|
||
onClick: () => {
|
||
remember();
|
||
setMarkOn(!markOn);
|
||
},
|
||
},
|
||
...(markOn ? [groupChip('wmColor'), groupChip('wmSize'), groupChip('wmFont')] : []),
|
||
];
|
||
|
||
// Every tab carries RESET, pinned outside the scrolling chips.
|
||
// RESET lights up only while the look differs from what reset() puts back —
|
||
// every clause has a matching setter there, or the star never goes out.
|
||
const resetDirty = useMemo(() => {
|
||
const a = recipe.adjustments;
|
||
const tweaked = (Object.keys(DEFAULT_ADJUSTMENTS) as (keyof typeof DEFAULT_ADJUSTMENTS)[])
|
||
.some((k) => a[k] !== DEFAULT_ADJUSTMENTS[k]);
|
||
return tweaked
|
||
|| Object.keys(a.hslBands ?? {}).length > 0
|
||
|| (simId ?? 'sim-provia') !== 'sim-provia'
|
||
|| frameId !== 'none' || crop !== 'none' || rotation !== 0 || straighten !== 0
|
||
|| 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.
|
||
const maskRulers: SliderDef[] = !selMask
|
||
? []
|
||
: [
|
||
{
|
||
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: selMask.contrast,
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
display: selMask.contrast > 0 ? `+${selMask.contrast}` : String(selMask.contrast),
|
||
edited: selMask.contrast !== 0,
|
||
onChange: (v) => setMaskKnob({ contrast: Math.round(v) }),
|
||
onReset: () => setMaskKnob({ contrast: 0 }),
|
||
},
|
||
{
|
||
key: 'mask-saturation',
|
||
label: 'SATURATION',
|
||
value: selMask.saturation,
|
||
min: -10,
|
||
max: 10,
|
||
step: 1,
|
||
display: selMask.saturation > 0 ? `+${selMask.saturation}` : String(selMask.saturation),
|
||
edited: selMask.saturation !== 0,
|
||
onChange: (v) => setMaskKnob({ saturation: Math.round(v) }),
|
||
onReset: () => setMaskKnob({ saturation: 0 }),
|
||
},
|
||
...(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();
|
||
},
|
||
});
|
||
|
||
// Shown where an unverified account would otherwise just see an empty list.
|
||
// The list is not empty because the account has nothing — it is shut until
|
||
// the address is proven, and saying so is the whole point of the chip.
|
||
const verifyChip = (): ChipDef => ({
|
||
key: 'verify-now',
|
||
label: t('pro.verifyChip'),
|
||
pro: true,
|
||
onClick: promptPro,
|
||
});
|
||
|
||
const framesRow = (): ChipDef[] => [
|
||
// NO FRAME is not a chip of its own: clicking the frame already on the
|
||
// photo takes it off again, so the row carries one entry per frame.
|
||
...FRAMES.filter((frame) => frame.id !== 'none').map((frame) => ({
|
||
key: `fr:${frame.id}`,
|
||
label: t(frame.label),
|
||
active: frameId === frame.id,
|
||
pro: PRO_FRAMES.includes(frame.id),
|
||
onClick: () => {
|
||
if (PRO_FRAMES.includes(frame.id) && !pro) {
|
||
promptPro();
|
||
return;
|
||
}
|
||
setOpenParam(null);
|
||
setOpenGroup(null);
|
||
remember();
|
||
setFrameId(frameId === frame.id ? 'none' : frame.id);
|
||
},
|
||
})),
|
||
groupChip('crop'),
|
||
// The chip names whichever of the two is off the straight — and both when
|
||
// both are — 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 a value instead of naming one. It
|
||
// measures the LOADED PHOTO — not the preview — so the number does not depend
|
||
// on the knobs already in play: pressing it twice lands on the same EV, the way
|
||
// Lightroom's Auto does. readHistogram gives the average luminance,
|
||
// autoExposureStops turns it into stops, and the EV knob takes it, rounded to
|
||
// the 0.1 that knob's own readout prints.
|
||
const autoExposure = 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({ exposureCompensation: Math.round(autoExposureStops(hist.lum) * 10) / 10 });
|
||
}
|
||
} 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);
|
||
}
|
||
};
|
||
|
||
// The main chip row of the active tab, exactly the phone's per-tab sets.
|
||
const chips: ChipDef[] = (() => {
|
||
switch (tab) {
|
||
case 'presets':
|
||
return [
|
||
groupChip('style'),
|
||
{
|
||
key: 'recipes-toggle',
|
||
label: 'RECIPES',
|
||
active: showRecipes,
|
||
onClick: () => {
|
||
setOpenGroup(null);
|
||
setOpenParam(null);
|
||
setShowRecipes((v) => !v);
|
||
},
|
||
},
|
||
// ponytail: no CREATE chip — making a recipe and filing the look on
|
||
// the stage are their own rail tabs (SAVE RECENT, CREATE RECIPES).
|
||
// IMPORT is a chip inside the RECIPES strip.
|
||
];
|
||
case 'favorited':
|
||
if (user && !pro) return [verifyChip()];
|
||
return saved.length === 0
|
||
? [{ key: 'no-favorites', label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE', disabled: true, onClick: () => {} }]
|
||
: saved.map((entry) => ({
|
||
key: String(entry.id),
|
||
label: entry.name,
|
||
// The look on the stage came from this entry: its name turns
|
||
// accent so the open recipe is visible in the list. A same-id
|
||
// preset that was merely filed under a new name must not light up,
|
||
// hence the name check too.
|
||
active: recipe.id === (entry.recipe as Recipe)?.id && recipe.name === entry.name,
|
||
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
|
||
}));
|
||
case 'photos': {
|
||
// MY PHOTOS: the account's own saved frames, newest first. Opening one
|
||
// reopens it for a further edit; that frame's own history, consent and
|
||
// removal live in its own column (see the render). A save always joins
|
||
// the landing strip — the owner takes it off again with that switch, or
|
||
// the curator with the admin slot.
|
||
const rows: ChipDef[] = [];
|
||
if (user && !pro) return [verifyChip()];
|
||
if (myPhotos.length === 0) {
|
||
rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} });
|
||
} else {
|
||
rows.push(
|
||
...myPhotos.map((p) => ({
|
||
key: `photo-${p.id}`,
|
||
label: p.title ?? `#${p.id}`,
|
||
active: p.id === openPhotoId,
|
||
onClick: () => void openMyPhoto(p),
|
||
}))
|
||
);
|
||
}
|
||
return rows;
|
||
}
|
||
case 'light':
|
||
return [
|
||
// AUTO rides at the head of the strip because it is the one chip here
|
||
// that is an ACTION rather than a knob or a look (see autoExposure).
|
||
{ key: 'auto', label: 'AUTO', onClick: () => void autoExposure() },
|
||
...paramChips(PARAM_DEFS.iq),
|
||
groupChip('dr'),
|
||
// TONE CURVE is not a row of sliders: it opens the graph on the photo
|
||
// (ToneCurvePanel), so the chip toggles that overlay rather than a
|
||
// ruler in the last column. It glows amber once the graph is off the
|
||
// diagonal, which is the only place the curve is reported.
|
||
{
|
||
key: 'curve',
|
||
label: 'TONE CURVE',
|
||
active: openParam === 'curve',
|
||
amberValue: curveIsActive(recipe.adjustments.toneCurve),
|
||
onClick: () => toggleParam('curve'),
|
||
},
|
||
];
|
||
case 'wb':
|
||
return [
|
||
{
|
||
// TEMP is the one strip chip that names its value — nothing else
|
||
// does, and it names it even at the neutral pair: TEMP AUTO.
|
||
...groupChip('temp'),
|
||
label: 'TEMP',
|
||
value: wbLabel(),
|
||
},
|
||
...paramChips(PARAM_DEFS.wb.filter((p) => p.key !== 'temperature')),
|
||
groupChip('cx'),
|
||
groupChip('cxb'),
|
||
];
|
||
case 'fx': {
|
||
// FX's tools are not looks: each is a strip of its own, opened from the
|
||
// row and holding the two chips that arm the pointer — FIX's brushes and
|
||
// GRADIENT MASK's shapes. The strip's own chip carries the name and glows
|
||
// amber while there is something inside it to clear, so the row above the
|
||
// look chips stays a row of looks.
|
||
return [
|
||
{
|
||
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');
|
||
},
|
||
},
|
||
{
|
||
key: 'gradient-mask',
|
||
label: 'GRADIENT MASK',
|
||
active: openGroup === 'gradient',
|
||
amberValue: masks.length > 0,
|
||
onClick: () => {
|
||
setPicking(false);
|
||
setBrushTool(null);
|
||
toggleGroup('gradient');
|
||
},
|
||
},
|
||
{ key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono },
|
||
// GRAIN is a strip of its own — amount, size, and the count they add up
|
||
// to — so it is one chip here and its two knobs live inside it; the
|
||
// chip keeps the place the knob had in this row.
|
||
...PARAM_DEFS.filters.flatMap((p): ChipDef[] =>
|
||
p.key === 'grain' ? [groupChip('grain')] : p.key === 'grainSize' ? [] : paramChips([p])
|
||
),
|
||
];
|
||
}
|
||
case 'hsl': {
|
||
// The mixer is PRO: the picker, the eight bands and the three
|
||
// image-wide knobs all come with the account, so a guest is offered the
|
||
// reason rather than a wall of dead chips.
|
||
if (!pro) return [{ key: 'hsl-pro', label: t('pro.hslChip'), pro: true, onClick: promptPro }];
|
||
// PICK arms the eyedropper; the eight band chips choose which band the
|
||
// ruler edits, and the ruler itself is opened by the three knobs below.
|
||
// Those three are the image's own move, not the band's: the band chips
|
||
// pick which colour the panel on the photo edits, while HUE/SAT/LUM
|
||
// below the divider move every hue in the frame at once.
|
||
const own = [recipe.adjustments.hslHue ?? 0, recipe.adjustments.hslSat ?? 0, recipe.adjustments.hslLum ?? 0];
|
||
const knob = (key: string, label: string, i: number): ChipDef => ({
|
||
key,
|
||
label,
|
||
value: own[i] > 0 ? `+${own[i]}` : String(own[i]),
|
||
active: openParam === key,
|
||
amberValue: openParam !== key && own[i] !== 0,
|
||
onClick: () => toggleParam(key),
|
||
});
|
||
return [
|
||
{ key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => { setBrushTool(null); setMaskTool(null); setPicking((v) => !v); } },
|
||
...HSL_BANDS.map((b): ChipDef => {
|
||
const band = recipe.adjustments.hslBands?.[b.id];
|
||
const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0);
|
||
return {
|
||
key: `hsl-band-${b.id}`,
|
||
label: b.label,
|
||
// The chip names a colour, so it shows the colour it names.
|
||
color: hslToHex(b.hue, 70, 50),
|
||
active: hslBand === b.id,
|
||
amberValue: hslBand !== b.id && moved,
|
||
onClick: () => setHslBand(b.id),
|
||
};
|
||
}),
|
||
// The divider keeps the three image-wide sliders out of the colour
|
||
// row: the row above picks a colour, these three move them all.
|
||
{ key: 'hsl-image', label: 'IMAGE', disabled: true, onClick: () => {} },
|
||
knob('hsl.h', 'HUE', 0),
|
||
knob('hsl.s', 'SAT', 1),
|
||
knob('hsl.l', 'LUM', 2),
|
||
];
|
||
}
|
||
case 'frame':
|
||
// WATERMARK is a chip like the rest: its own sub-panel opens as the next
|
||
// column (see the wm column in the render), so the frame chips stay put.
|
||
return framesRow();
|
||
// SAVE RECENT draws its own button and list, CREATE its own form; they
|
||
// are not chip rows like the rest.
|
||
case 'save':
|
||
case 'create':
|
||
return [];
|
||
default:
|
||
return [];
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
})();
|
||
|
||
// 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 }] : []),
|
||
];
|
||
|
||
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;
|
||
return [
|
||
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
|
||
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick).map((c) =>
|
||
// A hand-straightened frame is no longer on a clean quarter turn: the
|
||
// angle chips let go the moment the fine angle leaves 0, so the strip
|
||
// shows which of the two is actually steering the photo.
|
||
g === 'rotate' && straighten !== 0 && c.key !== 'rotate:reset' ? { ...c, active: false } : c
|
||
),
|
||
// The frame is on the photo but the crop is not in force until APPLY.
|
||
...(g === 'crop' && cropEditing
|
||
? [
|
||
{ key: 'crop-apply', label: t('crop.apply'), amberValue: true, onClick: applyCrop },
|
||
{ key: 'crop-cancel', label: t('crop.cancel'), onClick: cancelCrop },
|
||
]
|
||
: []),
|
||
// GRAIN's third entry is a READOUT: the clump count the two knobs add up to,
|
||
// in the print's own terms. Nothing to pick, so it is inert — it moves when a
|
||
// knob moves.
|
||
...(g === 'grain' ? [{ key: 'grain-inch', label: grainInch(), disabled: true, onClick: () => {} }] : []),
|
||
// STRAIGHTEN is the ROTATE strip's one entry that opens a ruler instead of
|
||
// picking a value: the quarter turns are choices, the angle is a knob.
|
||
...(g === 'rotate'
|
||
? [
|
||
// 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'),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
};
|
||
|
||
// RECIPES = this account's own files (SAVE RECENT) + what this session made
|
||
// but never filed + the bundled presets. A CREATE that a signed-in account
|
||
// stored lands in `saved` too, so it is dropped from `created` by name here.
|
||
// IMPORT takes the .recipe file the phone (or another account) exports — same
|
||
// envelope, same parser, so a look crosses devices intact.
|
||
const recipeStrip: ChipDef[] = [
|
||
{ key: 'recipes-import', label: 'IMPORT .RECIPE', onClick: pickRecipeFile },
|
||
...saved.map((entry) => ({
|
||
key: `saved-${entry.id}`,
|
||
label: entry.name,
|
||
active: recipe.name === entry.name,
|
||
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
|
||
})),
|
||
...created
|
||
.filter((entry) => !saved.some((s) => s.name.trim().toLowerCase() === entry.name.trim().toLowerCase()))
|
||
.map((entry) => ({
|
||
key: entry.id,
|
||
label: entry.name,
|
||
active: recipe.id === entry.id,
|
||
drag: entry.id,
|
||
onClick: () => applyRecipe(entry),
|
||
})),
|
||
...DEFAULT_RECIPES.map((preset) => ({
|
||
key: preset.id,
|
||
label: preset.name,
|
||
active: recipe.name === preset.name && !recipe.isCustom,
|
||
drag: preset.id,
|
||
onClick: () => applyRecipe({ ...preset }),
|
||
})),
|
||
];
|
||
|
||
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the
|
||
// open chip's own panel, the open group's options, the open ruler. Each level
|
||
// is a column of its own, so a child never hides the column it came from.
|
||
const wmOpen = isWmPanel(openGroup);
|
||
const optGroup = openGroup && openGroup !== 'wm' ? openGroup : null;
|
||
|
||
// The lines GPS prints, spelled exactly as the engine spells them, so the box
|
||
// that frames them is the box the file gets.
|
||
const gpsLines = useMemo(() => {
|
||
if (!gpsInfo) return [];
|
||
const out: string[] = [];
|
||
// 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]);
|
||
|
||
const status = busy;
|
||
|
||
return (
|
||
<div className="shell">
|
||
<TopBar
|
||
recipeName={recipe.name}
|
||
pro={pro}
|
||
user={user}
|
||
exporting={exporting}
|
||
savingPhoto={savingPhoto}
|
||
canUndo={undo.length > 0}
|
||
canRedo={redo.length > 0}
|
||
onUndo={undoLast}
|
||
onRedo={redoLast}
|
||
onReset={reset}
|
||
onSavePhoto={() => void saveThenMaybeClear()}
|
||
onExport={openExportPicker}
|
||
photoCount={myPhotos.length}
|
||
photoMax={photoCap}
|
||
onAuth={() => setAuthMode('login')}
|
||
onSignup={() => setAuthMode('signup')}
|
||
onLogout={async () => {
|
||
await api.logout().catch(() => undefined);
|
||
await clearSession();
|
||
// the stage still holds the old photo in memory; a reload is the one
|
||
// line that empties it and every knob with it.
|
||
window.location.reload();
|
||
}}
|
||
/>
|
||
|
||
{/* A signed-in account whose address is unproven is served as a guest, so
|
||
the studio says so once, at the top, instead of letting each locked
|
||
chip explain it on its own. */}
|
||
{user && !pro ? (
|
||
<div className="verify-bar" role="status">
|
||
<span>{t('pro.unverifiedBar', { email: user.email })}</span>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
data-key="verify-resend"
|
||
disabled={resent}
|
||
onClick={() => void resendVerification()}
|
||
>
|
||
{resent ? t('pro.resent') : t('pro.resend')}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="workspace">
|
||
<ToolRail tab={tab} onTab={setTab} onDropRecipe={favoriteById} signedIn={!!user} />
|
||
|
||
<div className="chips">
|
||
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
|
||
<div className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}`} data-col="main">
|
||
{tab === 'create' ? (
|
||
<RecipeCreatePanel
|
||
currentBase={recipe.baseFilter}
|
||
current={recipe.adjustments}
|
||
signedIn={pro}
|
||
resetToken={createReset}
|
||
onPreview={previewCreatedRecipe}
|
||
onSave={(draft) => void addCreatedRecipe(draft)}
|
||
onExport={exportRecipeFile}
|
||
/>
|
||
) : tab === 'save' ? (
|
||
<>
|
||
{/* SAVE RECENT files the look standing on the stage — sim, WB,
|
||
light, FX and frame — as a recipe of this account's own. */}
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
data-key="save-recent"
|
||
onClick={() => void saveRecipe()}
|
||
>
|
||
{t('save.recent')}
|
||
</button>
|
||
<p className="hint">{t('save.recentHint')}</p>
|
||
{saved.length === 0 ? (
|
||
<p className="hint">{t('save.recentEmpty')}</p>
|
||
) : (
|
||
<div className="saved-list">
|
||
{saved.map((entry) => (
|
||
<div className="saved-row" key={entry.id}>
|
||
<button
|
||
type="button"
|
||
className="chip"
|
||
data-key={`saved-${entry.id}`}
|
||
aria-pressed={recipe.name === entry.name}
|
||
onClick={() => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name })}
|
||
>
|
||
{entry.name}
|
||
</button>
|
||
{/* Only this account can delete its own file — the API
|
||
scopes the delete by user, the button mirrors it. */}
|
||
<button
|
||
type="button"
|
||
className="chip"
|
||
data-key={`saved-del-${entry.id}`}
|
||
title={t('save.delete')}
|
||
aria-label={t('save.delete')}
|
||
onClick={() => void deleteRecipeEntry(entry)}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<ChipRow chips={chips} />
|
||
)}
|
||
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||
{/* The mixer's readout: the colour the eyedropper last read, as the
|
||
RGB bytes and the HSL the band is indexed by. */}
|
||
{tab === 'hsl' && pro ? (
|
||
<div className="hsl-readout" data-key="hsl-readout">
|
||
{sample && picked ? (
|
||
<>
|
||
<span
|
||
className="hsl-swatch"
|
||
data-key="hsl-swatch"
|
||
data-color={`rgb(${sample.r}, ${sample.g}, ${sample.b})`}
|
||
style={{ background: `rgb(${sample.r}, ${sample.g}, ${sample.b})` }}
|
||
/>
|
||
<span className="hsl-line" data-key="hsl-rgb">
|
||
R {sample.r} · G {sample.g} · B {sample.b}
|
||
</span>
|
||
<span className="hsl-line" data-key="hsl-hsl">
|
||
{Math.round(picked.h)}° · {Math.round(picked.s * 100)}% · {Math.round(picked.l * 100)}%
|
||
</span>
|
||
<span className="hsl-line" data-key="hsl-band">
|
||
{HSL_BANDS.find((b) => b.id === hslBand)?.label}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<p className="hint">{t('hsl.hint')}</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
{/* RESET is the way out of an edit, not one more chip: it sits under
|
||
a rule at the foot of the column so it reads as its own thing. */}
|
||
<div className="reset-row">
|
||
<ChipRow chips={[resetChip()]} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* FRAME's WATERMARK chip opens the two collapses, one per
|
||
watermark type: the header chip, then that mark's own controls */}
|
||
{wmOpen ? (
|
||
<div className="col col-sub strip wm-extra" data-col="wm">
|
||
<ChipRow chips={[{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }]} />
|
||
|
||
{/* GPS WATERMARK — its colour, its face, its two rows, its place */}
|
||
<div className="wm-sec" data-key="wm-sec-gps">
|
||
<ChipRow chips={gpsChips()} />
|
||
{useGeotag ? (
|
||
<>
|
||
<input
|
||
className="field"
|
||
value={place}
|
||
placeholder={t('wm.placePh')}
|
||
onChange={(e) => setPlace(e.target.value)}
|
||
/>
|
||
<p className="hint">
|
||
{gps
|
||
? t(gps.source === 'device' ? 'wm.hasDevice' : gps.source ? 'wm.hasExif' : 'wm.hasManual')
|
||
: t('wm.noExif')}
|
||
</p>
|
||
{!gps ? (
|
||
<>
|
||
<div className="chip-row">
|
||
<button
|
||
type="button"
|
||
className="chip"
|
||
data-key="gps-locate"
|
||
disabled={locating}
|
||
onClick={() => locateMe()}
|
||
>
|
||
{locating ? t('wm.locating') : t('wm.useLocation')}
|
||
</button>
|
||
</div>
|
||
<div className="chip-row">
|
||
<input
|
||
className="field"
|
||
style={{ width: '48%' }}
|
||
placeholder={t('wm.lat')}
|
||
value={gpsManual.lat}
|
||
onChange={(e) => setGpsManual((prev) => ({ ...prev, lat: e.target.value }))}
|
||
onBlur={() => {
|
||
const lat = Number(gpsManual.lat);
|
||
const lng = Number(gpsManual.lng);
|
||
if (Number.isFinite(lat) && Number.isFinite(lng) && gpsManual.lat && gpsManual.lng) {
|
||
setGps({ latitude: lat, longitude: lng, locality: place, timestamp: capturedAt ?? Date.now() });
|
||
setUseGeotag(true);
|
||
}
|
||
}}
|
||
/>
|
||
<input
|
||
className="field"
|
||
style={{ width: '48%' }}
|
||
placeholder={t('wm.lng')}
|
||
value={gpsManual.lng}
|
||
onChange={(e) => setGpsManual((prev) => ({ ...prev, lng: e.target.value }))}
|
||
/>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* CUSTOM WATERMARK — its text, its colour, its size, its face */}
|
||
<div className="wm-sec" data-key="wm-sec-custom">
|
||
<ChipRow chips={customChips()} />
|
||
{markOn ? (
|
||
<input
|
||
className="field"
|
||
value={markText}
|
||
placeholder="TYPE WATERMARK TEXT"
|
||
maxLength={80}
|
||
onChange={(e) => setMarkText(e.target.value)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */}
|
||
{optGroup ? (
|
||
<div className="col col-sub strip" data-col="options">
|
||
<ChipRow chips={stripChips(optGroup)} />
|
||
{/* A watermark's colour: the presets above are a shortcut, the
|
||
well under them picks any colour at all. */}
|
||
{optGroup === 'wmColor' || optGroup === 'gpsColor' ? (
|
||
<label className="color-well" data-key={`wm-color-${optGroup}`}>
|
||
<input
|
||
type="color"
|
||
data-key={`wm-color-input-${optGroup}`}
|
||
value={optGroup === 'wmColor' ? markStyle.color : gpsStyle.color}
|
||
onChange={(e) => setWmColor(e.target.value)}
|
||
/>
|
||
<span className="val">{optGroup === 'wmColor' ? markStyle.color : gpsStyle.color}</span>
|
||
</label>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 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. */}
|
||
{maskTool && selMask ? (
|
||
<div className="col col-sub strip" data-col="mask">
|
||
<ChipRow
|
||
chips={[
|
||
{ key: 'mask-kind', label: selMask.kind.toUpperCase(), active: true, onClick: () => {} },
|
||
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
|
||
]}
|
||
/>
|
||
{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 === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
|
||
<ChipRow chips={recipeStrip} />
|
||
</div>
|
||
) : null}
|
||
|
||
{/* MY PHOTOS' own column, for the frame standing on the stage: the
|
||
looks it carried before its last saves (newest first, three kept)
|
||
and the switch that puts it on the landing film strip. */}
|
||
{tab === 'photos' && openPhoto ? (
|
||
<div className="col col-sub strip" data-col="history">
|
||
{openPhoto.history?.length ? (
|
||
<ChipRow
|
||
chips={[
|
||
{ key: 'hist-title', label: t('myp.history'), disabled: true, onClick: () => {} },
|
||
...openPhoto.history.map((look, i) => ({
|
||
key: `hist-${i}`,
|
||
label: look.name || `#${i + 1}`,
|
||
// The picked look is marked like the open recipe in the
|
||
// RECIPES list — accent, so it follows the theme.
|
||
active: histPick === i,
|
||
onClick: () => {
|
||
// An older look is loaded the same way a frame is: the
|
||
// preview goes first, then the look it names is painted.
|
||
clearPreview();
|
||
applyRecipe({ ...look });
|
||
setHistPick(i);
|
||
},
|
||
})),
|
||
]}
|
||
/>
|
||
) : (
|
||
<p className="hint">{t('myp.historyEmpty')}</p>
|
||
)}
|
||
<label className="consent">
|
||
<input
|
||
type="checkbox"
|
||
data-key="strip-open"
|
||
checked={openPhoto.consent !== false}
|
||
onChange={() => void togglePhotoConsent(openPhoto)}
|
||
/>
|
||
<span>{t('myp.consent')}</span>
|
||
</label>
|
||
{/* The frame's own removal. The row goes, and with it the looks
|
||
its history kept, so the dialog asks before it happens. */}
|
||
<button
|
||
type="button"
|
||
className="btn adm-danger"
|
||
data-key="photo-remove"
|
||
onClick={() => setRemoving(openPhoto)}
|
||
>
|
||
{t('myp.remove')}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* column 4 — the open ruler, sliders stacked */}
|
||
{slider ? (
|
||
<div className="col col-slider" data-col="slider">
|
||
<SliderRow slider={slider} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<ImageStage
|
||
previewUrl={previewUrl}
|
||
// Nothing to show until the neutral render lands: the file itself is
|
||
// the wrong geometry the moment the photo has been turned or cropped.
|
||
originalUrl={comparing ? compareUrl : source?.url ?? null}
|
||
compare={comparing}
|
||
onCompareChange={setComparing}
|
||
busy={status}
|
||
error={error}
|
||
peek={peek}
|
||
onPeek={setPeek}
|
||
onFile={(file) => void loadFile(file)}
|
||
onCamera={cameraSupported() ? () => setShooting(true) : 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
|
||
}
|
||
/>
|
||
</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}
|
||
|
||
{shooting && engine ? (
|
||
<CameraView
|
||
recipe={recipe}
|
||
// A crop being framed is not committed, so the live view composes on
|
||
// the bare photo — exactly the geometry the preview behind it uses.
|
||
frameId={cropEditing ? 'none' : frameId}
|
||
options={stageOptions}
|
||
useGeotag={useGeotag}
|
||
gpsInfo={gpsInfo}
|
||
assets={engine.assets}
|
||
onShot={takeShot}
|
||
onClose={() => setShooting(false)}
|
||
/>
|
||
) : null}
|
||
|
||
{choosingExport ? (
|
||
<ExportModal
|
||
longest={editedLongest}
|
||
maxDim={TIERS[pro ? 'user' : 'guest'].maxExportDim}
|
||
onPick={(target) => {
|
||
setChoosingExport(false);
|
||
void exportPhoto(target);
|
||
}}
|
||
onClose={() => setChoosingExport(false)}
|
||
/>
|
||
) : null}
|
||
|
||
{removing ? (
|
||
<DeletePhotoModal
|
||
id={removing.id}
|
||
name={removing.title ?? `#${removing.id}`}
|
||
historyCount={removing.history?.length ?? 0}
|
||
onConfirm={() => void removeMyPhoto(removing)}
|
||
onClose={() => setRemoving(null)}
|
||
/>
|
||
) : null}
|
||
|
||
{authMode ? (
|
||
<AuthModal
|
||
initialMode={authMode}
|
||
email={user?.email}
|
||
onClose={() => setAuthMode(null)}
|
||
onDone={async () => {
|
||
setAuthMode(null);
|
||
const me = await api.me().catch(() => null);
|
||
if (me) setUser(me.user);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|