web: a film sim is colour and tone only
The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME, CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID. Grain, clarity, saturation and light moves were dropped from their `adjustments`, so a sim is a clean starting point and the general knobs read their defaults while the look still lands on the pixels. LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in where the Exposure slider applies, so the picture gets the lift and the parameter stays at 0. Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the WATERMARK chip column, the real admin stats, and the fix that stopped presets from doubling and a frame from refusing to come off when a photo was reopened (/file is the finished render, /base the editable pixels).
This commit is contained in:
+382
-56
@@ -35,9 +35,23 @@ 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
|
||||
@@ -59,6 +73,19 @@ const FRAMES: { id: FrameId; label: MsgKey }[] = [
|
||||
{ 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
|
||||
@@ -146,6 +173,26 @@ interface LoadedSource {
|
||||
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 };
|
||||
const DEFAULT_MARK_STYLE: MarkStyle = { color: '#ffffff', size: 1, x: 0.5, y: 0.92 };
|
||||
|
||||
// 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 };
|
||||
const DEFAULT_GPS_STYLE: GpsStyle = { color: '#f59e0b', showName: true, showTime: true, size: 1.2, x: 0.06, y: 0.7 };
|
||||
|
||||
// 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 {
|
||||
@@ -159,9 +206,9 @@ interface Look {
|
||||
straighten: number;
|
||||
markOn: boolean;
|
||||
markText: string;
|
||||
markStyle: { color: string; size: number };
|
||||
markStyle: MarkStyle;
|
||||
useGeotag: boolean;
|
||||
gpsStyle: { color: string; showName: boolean; showTime: boolean };
|
||||
gpsStyle: GpsStyle;
|
||||
}
|
||||
|
||||
export function Workspace() {
|
||||
@@ -213,14 +260,20 @@ export function Workspace() {
|
||||
// ISO off the loaded photo's EXIF; null means the strip prints AUTO.
|
||||
const [iso, setIso] = 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);
|
||||
const [gpsStyle, setGpsStyle] = useState(
|
||||
() => session.gpsStyle ?? { color: '#f59e0b', showName: true, showTime: true }
|
||||
);
|
||||
// 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);
|
||||
const [markStyle, setMarkStyle] = useState(() => session.markStyle ?? { color: '#ffffff', size: 1 });
|
||||
// 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
|
||||
@@ -327,9 +380,9 @@ export function Workspace() {
|
||||
straighten: 0,
|
||||
markOn: false,
|
||||
markText: '',
|
||||
markStyle: { color: '#ffffff', size: 1 },
|
||||
markStyle: { ...DEFAULT_MARK_STYLE },
|
||||
useGeotag: false,
|
||||
gpsStyle: { color: '#f59e0b', showName: true, showTime: true },
|
||||
gpsStyle: { ...DEFAULT_GPS_STYLE },
|
||||
};
|
||||
}, []);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -340,6 +393,18 @@ export function Workspace() {
|
||||
// like a guest — watermarked 2048px export, no saving, no PRO tools. An
|
||||
// allowlisted admin is trusted by construction and counts as verified.
|
||||
const pro = !!user?.verified;
|
||||
// Admins are the server's own operators: the API skips MAX_PHOTOS_PER_USER
|
||||
// for them (server.ts), so the studio stops counting at the same place. null
|
||||
// means "no cap" all the way down to SAVE PHOTO's label.
|
||||
const photoCap = user?.admin ? null : MAX_PHOTOS;
|
||||
// What the stamp's name line prints: the visitor's own PLACE NAME wins over
|
||||
// whatever the fix carried, because neither a phone's position nor a photo's
|
||||
// EXIF ever brings one, and typing it after locating must still show up.
|
||||
// Memoised: this object is a dependency of the preview and the export, and a
|
||||
// fresh one on every render would re-arm the render effect with its own
|
||||
// output — the stage would repaint itself forever and the panel would stop
|
||||
// answering clicks.
|
||||
const gpsInfo = useMemo(() => (gps ? { ...gps, locality: place.trim() || gps.locality } : null), [gps, place]);
|
||||
const [saved, setSaved] = useState<SavedRecipe[]>([]);
|
||||
// Bumped by RESET while CREATE is open: the form above the button listens for
|
||||
// it and drops back to its defaults.
|
||||
@@ -450,7 +515,7 @@ export function Workspace() {
|
||||
// measures exactly what the engine will cut.
|
||||
frameId: cropEditing ? 'none' : frameId,
|
||||
useGeotag,
|
||||
gpsInfo: gps,
|
||||
gpsInfo,
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||||
@@ -458,8 +523,8 @@ export function Workspace() {
|
||||
photoRotation: rotation,
|
||||
photoStraighten: straighten,
|
||||
cropRect: cropLive ? cropRect : null,
|
||||
watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null,
|
||||
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null,
|
||||
watermark: markOn && markText ? { text: markText, ...markStyle } : null,
|
||||
gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null,
|
||||
},
|
||||
});
|
||||
if (token !== renderToken.current) return;
|
||||
@@ -477,7 +542,7 @@ export function Workspace() {
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gps, gpsStyle, markOn, markText, markStyle]);
|
||||
}, [engine, previewBytes, recipe, frameId, cropEditing, cropLive, cropRect, rotation, straighten, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle]);
|
||||
|
||||
// The source copy behind previewBytes, re-made only when previewMax moves:
|
||||
// applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back.
|
||||
@@ -507,6 +572,51 @@ export function Workspace() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
// 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.
|
||||
const locateMe = useCallback(() => {
|
||||
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: Date.now(), source: 'device' });
|
||||
setUseGeotag(true);
|
||||
setLocating(false);
|
||||
resolvePlace(latitude, longitude);
|
||||
},
|
||||
() => {
|
||||
setLocating(false);
|
||||
locDeniedRef.current = true;
|
||||
setError(t('wm.locFail'));
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
|
||||
);
|
||||
}, [place, t, resolvePlace]);
|
||||
|
||||
const adoptPhoto = useCallback(
|
||||
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
|
||||
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
|
||||
@@ -533,6 +643,9 @@ export function Workspace() {
|
||||
previewJobRef.current++;
|
||||
setPreviewBytes(preview);
|
||||
setGps(found);
|
||||
// EXIF gave a position but never a name; ask the API for one. PRO only —
|
||||
// the stamp is a PRO tool, and the lookup is a call to a public geocoder.
|
||||
if (found && pro) resolvePlace(found.latitude, found.longitude);
|
||||
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
|
||||
@@ -542,8 +655,13 @@ export function Workspace() {
|
||||
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();
|
||||
},
|
||||
[defaultLook]
|
||||
[defaultLook, pro, locateMe, resolvePlace]
|
||||
);
|
||||
|
||||
const loadFile = useCallback(
|
||||
@@ -603,6 +721,17 @@ export function Workspace() {
|
||||
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) {
|
||||
@@ -627,6 +756,47 @@ export function Workspace() {
|
||||
[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
|
||||
@@ -932,7 +1102,7 @@ export function Workspace() {
|
||||
// The file is the preview: an uncommitted frame is not exported.
|
||||
frameId: cropEditing ? 'none' : frameId,
|
||||
useGeotag,
|
||||
gpsInfo: gps,
|
||||
gpsInfo,
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||||
@@ -942,11 +1112,11 @@ export function Workspace() {
|
||||
photoStraighten: straighten,
|
||||
cropRect: cropLive ? cropRect : null,
|
||||
watermark: markOn && markText
|
||||
? { text: markText, x: 0.5, y: 0.92, ...markStyle }
|
||||
? { text: markText, ...markStyle }
|
||||
: tier.watermark
|
||||
? { text: tier.watermark, x: 0.5, y: 0.94, color: '#ffffff', size: 1 }
|
||||
: null,
|
||||
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null,
|
||||
gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null,
|
||||
},
|
||||
});
|
||||
if (!out) throw new Error('render returned null');
|
||||
@@ -975,7 +1145,7 @@ export function Workspace() {
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]);
|
||||
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, place, pro, promptPro, proLookInUse, t, strip, photoSettings]);
|
||||
|
||||
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
|
||||
// The upload is left clean (no burned strip) so the landing reel can draw its
|
||||
@@ -993,8 +1163,8 @@ export function Workspace() {
|
||||
// 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 && myPhotos.length >= MAX_PHOTOS) {
|
||||
setError(t('save.photoFull', { max: MAX_PHOTOS }));
|
||||
if (!open && photoCap !== null && myPhotos.length >= photoCap) {
|
||||
setError(t('save.photoFull', { max: photoCap }));
|
||||
return;
|
||||
}
|
||||
// A frame being filed for the first time is asked for a name — the folder
|
||||
@@ -1018,7 +1188,7 @@ export function Workspace() {
|
||||
recipe,
|
||||
frameId: cropEditing ? 'none' : frameId,
|
||||
useGeotag,
|
||||
gpsInfo: gps,
|
||||
gpsInfo,
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
wallframeLandscape: frameId === 'wallframe-landscape',
|
||||
@@ -1027,14 +1197,35 @@ export function Workspace() {
|
||||
photoRotation: rotation,
|
||||
photoStraighten: straighten,
|
||||
cropRect: cropLive ? cropRect : null,
|
||||
watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null,
|
||||
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null,
|
||||
watermark: markOn && markText ? { text: markText, ...markStyle } : null,
|
||||
gpsWatermark: useGeotag && gps ? { ...gpsStyle } : null,
|
||||
},
|
||||
});
|
||||
if (!out) throw new Error('render returned null');
|
||||
// The pixels under the look: the same geometry, none of the grade, frame
|
||||
// or stamp. The recipe does not carry the geometry, so it has to stay in
|
||||
// these pixels — everything else is what reopening re-applies, and
|
||||
// applying it to the render is what doubled the frame and the look.
|
||||
const base = await renderPhoto({
|
||||
source: src,
|
||||
recipe: BASE_RECIPE,
|
||||
frameId: 'none',
|
||||
assets: engine.assets,
|
||||
options: {
|
||||
dpi: 300,
|
||||
// The live preview sharpens once on top of this, so the base is left
|
||||
// unsharpened — the render the folder shows carries its own.
|
||||
sharpen: false,
|
||||
photoRotation: rotation,
|
||||
photoStraighten: straighten,
|
||||
cropRect: cropLive ? cropRect : null,
|
||||
},
|
||||
});
|
||||
if (!base) throw new Error('render returned null');
|
||||
const file = new File([out.bytes as BlobPart], `${strip.tag.slice(1).toLowerCase() || 'photo'}.jpg`, {
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
let savedId: number;
|
||||
if (open) {
|
||||
// The frame on the stage is one of the folder's own: it keeps its id and
|
||||
// its place, and the look it carried before this save lands in history.
|
||||
@@ -1044,13 +1235,18 @@ export function Workspace() {
|
||||
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);
|
||||
@@ -1059,7 +1255,30 @@ export function Workspace() {
|
||||
} finally {
|
||||
setSavingPhoto(false);
|
||||
}
|
||||
}, [pro, promptPro, engine, source, myPhotos, openPhotoId, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]);
|
||||
}, [pro, promptPro, engine, source, myPhotos, openPhotoId, photoCap, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gpsInfo, gpsStyle, rotation, straighten, markOn, markText, markStyle, strip, photoSettings, t]);
|
||||
|
||||
// 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.
|
||||
@@ -1072,15 +1291,25 @@ export function Workspace() {
|
||||
clearPreview();
|
||||
setHistPick(null);
|
||||
try {
|
||||
const res = await fetch(api.photoUrl(photo.id));
|
||||
// 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) {
|
||||
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 {
|
||||
@@ -1089,7 +1318,7 @@ export function Workspace() {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[loadFile, applyRecipe, clearPreview, t]
|
||||
[loadFile, applyRecipe, restoreLook, defaultLook, clearPreview, t]
|
||||
);
|
||||
|
||||
// The saved frame's own switch. The reel reads `consent`, so the tick answers
|
||||
@@ -1466,13 +1695,17 @@ export function Workspace() {
|
||||
}));
|
||||
|
||||
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;
|
||||
const wmChips = (): ChipDef[] => [
|
||||
{
|
||||
key: 'gps',
|
||||
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
|
||||
active: useGeotag,
|
||||
pro: true,
|
||||
disabled: !gps && !(gpsManual.lat && gpsManual.lng),
|
||||
disabled: !gps && !(gpsManual.lat && gpsManual.lng) && !canLocate,
|
||||
onClick: () => {
|
||||
if (!pro) {
|
||||
promptPro();
|
||||
@@ -1506,8 +1739,11 @@ export function Workspace() {
|
||||
|| Object.keys(a.hslBands ?? {}).length > 0
|
||||
|| (simId ?? 'sim-provia') !== 'sim-provia'
|
||||
|| frameId !== 'none' || crop !== 'none' || rotation !== 0 || straighten !== 0
|
||||
|| markOn || markText !== '' || markStyle.color !== '#ffffff' || markStyle.size !== 1
|
||||
|| useGeotag || gpsStyle.color !== '#f59e0b' || !gpsStyle.showName || !gpsStyle.showTime;
|
||||
|| 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
|
||||
|| useGeotag
|
||||
|| (Object.keys(DEFAULT_GPS_STYLE) as (keyof GpsStyle)[]).some((k) => gpsStyle[k] !== DEFAULT_GPS_STYLE[k]);
|
||||
}, [recipe, simId, frameId, crop, rotation, straighten, markOn, markText, markStyle, useGeotag, gpsStyle]);
|
||||
|
||||
const resetChip = (): ChipDef => ({
|
||||
@@ -1588,7 +1824,7 @@ export function Workspace() {
|
||||
const toggleMono = () => {
|
||||
remember();
|
||||
if (monoOn) {
|
||||
const back = monoPrev.current ?? { baseFilter: 'provia' as Recipe['baseFilter'], simId: 'sim-provia', name: 'PROVIPES' };
|
||||
const back = monoPrev.current ?? { baseFilter: 'provia' as Recipe['baseFilter'], simId: 'sim-provia', name: 'PROVIA' };
|
||||
setRecipe((prev) => ({ ...prev, baseFilter: back.baseFilter, name: back.name, isCustom: true }));
|
||||
setSimId(back.simId);
|
||||
} else {
|
||||
@@ -1794,6 +2030,26 @@ export function Workspace() {
|
||||
const wmOpen = isWmPanel(openGroup);
|
||||
const optGroup = openGroup && openGroup !== 'wm' ? openGroup : null;
|
||||
|
||||
// The lines GPS prints, spelled exactly as the engine spells them, so the box
|
||||
// that frames them is the box the file gets.
|
||||
const gpsLines = useMemo(() => {
|
||||
if (!gpsInfo) return [];
|
||||
const out: string[] = [];
|
||||
if (gpsStyle.showName) {
|
||||
out.push(
|
||||
gpsInfo.locality?.trim() || `${gpsInfo.latitude.toFixed(5)}, ${gpsInfo.longitude.toFixed(5)}`
|
||||
);
|
||||
}
|
||||
if (gpsStyle.showTime) {
|
||||
const d = new Date(gpsInfo.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
out.push(
|
||||
`${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}, [gpsInfo, gpsStyle.showName, gpsStyle.showTime]);
|
||||
|
||||
const status = busy;
|
||||
|
||||
return (
|
||||
@@ -1811,7 +2067,7 @@ export function Workspace() {
|
||||
onSavePhoto={() => void savePhotoToFolder()}
|
||||
onExport={exportPhoto}
|
||||
photoCount={myPhotos.length}
|
||||
photoMax={MAX_PHOTOS}
|
||||
photoMax={photoCap}
|
||||
onAuth={() => setAuthMode('login')}
|
||||
onSignup={() => setAuthMode('signup')}
|
||||
onLogout={async () => {
|
||||
@@ -1966,32 +2222,49 @@ export function Workspace() {
|
||||
placeholder={t('wm.placePh')}
|
||||
onChange={(e) => setPlace(e.target.value)}
|
||||
/>
|
||||
<p className="hint">{gps ? t('wm.hasExif') : t('wm.noExif')}</p>
|
||||
<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">
|
||||
<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: Date.now(), source: 'device' });
|
||||
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>
|
||||
<>
|
||||
<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: 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}
|
||||
@@ -2002,6 +2275,19 @@ export function Workspace() {
|
||||
{optGroup ? (
|
||||
<div className="col col-sub strip" data-col="options">
|
||||
<ChipRow chips={stripChips(optGroup)} />
|
||||
{/* A watermark's colour: the presets above are a shortcut, the
|
||||
well under them picks any colour at all. */}
|
||||
{optGroup === 'wmColor' || optGroup === 'gpsColor' ? (
|
||||
<label className="color-well" data-key={`wm-color-${optGroup}`}>
|
||||
<input
|
||||
type="color"
|
||||
data-key={`wm-color-input-${optGroup}`}
|
||||
value={optGroup === 'wmColor' ? markStyle.color : gpsStyle.color}
|
||||
onChange={(e) => setWmColor(e.target.value)}
|
||||
/>
|
||||
<span className="val">{optGroup === 'wmColor' ? markStyle.color : gpsStyle.color}</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{tab === 'presets' && showRecipes ? (
|
||||
@@ -2083,10 +2369,50 @@ export function Workspace() {
|
||||
onCropRectChange={setCropRect}
|
||||
onStageZoom={onStageZoom}
|
||||
canCompare={crop === 'none'}
|
||||
onSaveOriginal={() => void saveOriginal()}
|
||||
picking={picking}
|
||||
onPickColor={pickColor}
|
||||
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],
|
||||
onMove: setWatermarkPos,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(useGeotag && gps && gpsLines.length
|
||||
? [
|
||||
{
|
||||
key: 'gps',
|
||||
x: gpsStyle.x,
|
||||
y: gpsStyle.y,
|
||||
size: gpsStyle.size,
|
||||
baselineEm: GPS_BASELINE_EM,
|
||||
gapEm: GPS_GAP_EM,
|
||||
lines: gpsLines,
|
||||
onMove: setGpsPos,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []
|
||||
}
|
||||
straightenRuler={
|
||||
openParam === 'straighten' ? (
|
||||
<StraightenRuler value={straighten} onChange={setStraightenOnce} onReset={() => setStraightenOnce(0)} />
|
||||
|
||||
Reference in New Issue
Block a user