diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index da5382a..d8739af 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -13,7 +13,7 @@ import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; import { loadEngine } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; -import { download, fileToBytes, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps'; +import { download, fileToBytes, readCapturedAt, readGps, readIso, resizedJpeg, scaledJpeg } from './engine/imageOps'; import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session'; import { preloadSuperRes, upscaleJpeg } from './engine/superRes'; import { api, type Photo, type SavedRecipe, type User } from './api'; @@ -277,6 +277,12 @@ export function Workspace() { const [gps, setGps] = useState(() => session.gps ?? null); // ISO off the loaded photo's EXIF; null means the strip prints AUTO. const [iso, setIso] = useState(null); + // When the loaded photo was taken, off its own EXIF. The GPS stamp prints this + // and not the clock, whichever way the position was come by: a photo from the + // library can be years old, and the location typed in for it today is still the + // location of that day. Null for a file with no date, which then falls back to + // the visitor's clock — there is nothing else to believe. + const [capturedAt, setCapturedAt] = useState(null); const [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' }); const [locating, setLocating] = useState(false); // Once the visitor turns the prompt down, a photo without GPS stops asking on @@ -694,7 +700,11 @@ export function Workspace() { // 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(() => { + // 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; @@ -704,7 +714,7 @@ export function Workspace() { (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' }); + setGps({ latitude, longitude, locality: place, timestamp: when ?? capturedAt ?? Date.now(), source: 'device' }); setUseGeotag(true); setLocating(false); resolvePlace(latitude, longitude); @@ -716,13 +726,18 @@ export function Workspace() { }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 } ); - }, [place, t, resolvePlace]); + }, [place, t, resolvePlace, 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 @@ -762,7 +777,7 @@ export function Workspace() { // 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(); + if (!found && pro && canLocate && !locDeniedRef.current) locateMe(taken); }, [defaultLook, pro, locateMe, resolvePlace] ); @@ -2440,7 +2455,7 @@ export function Workspace() { className="chip" data-key="gps-locate" disabled={locating} - onClick={locateMe} + onClick={() => locateMe()} > {locating ? t('wm.locating') : t('wm.useLocation')} @@ -2456,7 +2471,7 @@ export function Workspace() { 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() }); + setGps({ latitude: lat, longitude: lng, locality: place, timestamp: capturedAt ?? Date.now() }); setUseGeotag(true); } }} diff --git a/docker/frontend/src/engine/imageOps.ts b/docker/frontend/src/engine/imageOps.ts index 62c30a5..51e5550 100644 --- a/docker/frontend/src/engine/imageOps.ts +++ b/docker/frontend/src/engine/imageOps.ts @@ -113,6 +113,27 @@ async function shrinkOrKeep(bytes: Uint8Array, maxDim: number, maxBytes: number) } } +// When the photo was taken, per its own EXIF. DateTimeOriginal is the shutter; +// the two file timestamps behind it are what an editor rewrote along the way. +// EXIF stores the time without a timezone, in the camera's local time, and exifr +// hands it back as a Date in ours — which is what a camera prints on a stamp, and +// what someone opening a 2019 photo today expects to read under the coordinates. +// Null when the file carries no date at all; the caller falls back to its clock. +export async function readCapturedAt(bytes: Uint8Array): Promise { + try { + const tags = (await exifr.parse(bytes, { + pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'], + })) as Record | undefined; + for (const tag of ['DateTimeOriginal', 'CreateDate', 'ModifyDate']) { + const value = tags?.[tag]; + if (value instanceof Date && Number.isFinite(value.getTime())) return value.getTime(); + } + } catch { + // Unreadable EXIF is the same as no EXIF here. + } + return null; +} + // EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect. // Returns null when the photo has none — the caller then offers manual entry. export async function readGps(bytes: Uint8Array): Promise { @@ -122,7 +143,8 @@ export async function readGps(bytes: Uint8Array): Promise { return { latitude: gps.latitude, longitude: gps.longitude, - timestamp: Date.now(), + // The stamp prints when the photo was taken, not when it was opened. + timestamp: (await readCapturedAt(bytes)) ?? Date.now(), source: 'exif', }; } catch {