From 3bfa82e7f221712792ba38c52e4b9cef13bb6d04 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 23 Sep 2026 09:22:19 +0700 Subject: [PATCH] web: stamp the photo's own day on the GPS mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GPS mark printed Date.now(), so a photo taken in 2019 carried the day it was opened. It now prints the frame's EXIF date — DateTimeOriginal, falling back on CreateDate then ModifyDate — wherever the position came from: - readCapturedAt() reads the date off the file, and readGps() uses it for a position found in the same EXIF. - adoptPhoto holds it in its own state, so a frame with a date but no position still stamps the date when the position is typed in by hand. - The device's own position stamps it too. That path runs inside adoptPhoto, where the render still holds the previous photo's date, so locateMe takes the date as an argument rather than reading state — the panel's own button, which has no such date to hand, passes none and reads the state as before. A file with no date at all still falls back on the visitor's clock: there is nothing else to believe. --- docker/frontend/src/App.tsx | 29 +++++++++++++++++++------- docker/frontend/src/engine/imageOps.ts | 24 ++++++++++++++++++++- 2 files changed, 45 insertions(+), 8 deletions(-) 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 {