mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
fix(journey): read GPS and the EXIF time offset from uploaded photos (#2512)
The capture backfill for local Journey uploads asked exifr for
'latitude' and 'longitude'. Those are not tags: exifr derives them from
the GPS tags, and a pick of only the derived names reads no GPS block at
all, so every uploaded photo lost its location and never showed on the
journey map. DateTimeOriginal was revived by exifr as a Date in the
server's time zone and OffsetTimeOriginal was never read, so a photo
taken at 15:52 in France was stored as 20:52 UTC on a server running in
Chicago. Impossible stamps were rolled over into real dates as well
(0000:00:00 became 1899, 30 February became 2 March).
The reader now picks the raw GPS tags and reads the stamps unrevived.
exifCaptureInstant validates the stamp and applies the first well formed
offset, preferring the tag that belongs to the stamp (OffsetTimeOriginal
for DateTimeOriginal, OffsetTimeDigitized for CreateDate). A zone written
onto the stamp itself (' UTC', 'GMT', 'Z', '+02:00') wins over the tags.
Without a usable offset it keeps the old reading in the server zone. A
stamp that is not a real date is dropped, CreateDate stands in when
DateTimeOriginal is unusable, and coordinates are kept only as a valid
pair. A GPS block of zeros, which a receiver without a fix writes, counts
as no location instead of a pin at 0,0.
Photos uploaded before this fix keep their stored time and location for
now; correcting them is left for a later release.
StorageService.withLocalFile now also removes its temp file when the
download itself fails, not only when the consumer throws.
The stamp is text any upload sets, so exifCaptureInstant turns down one
longer than 64 characters before any pattern runs, and the pattern has
no greedy tail in front of an end anchor, which cost quadratic time on a
long run of digits.
The REST uploads (entry, gallery) and the plugin journal.addEntryPhoto call
share this one reader, so both are fixed. The MCP journey tools only
attach provider photos, which never reach it.
This commit is contained in:
@@ -189,6 +189,79 @@ export function dayStartEpochSeconds(day: string, tzOffsetMinutes = 0): number {
|
||||
}
|
||||
|
||||
|
||||
// 'YYYY:MM:DD HH:MM:SS' as EXIF writes it. The dashed date is what some
|
||||
// converters produce and what exifr's own reviver accepted, so it keeps working,
|
||||
// and so does a field written with one digit ('2026:5:30 9:05:00'), which that
|
||||
// reviver read as well. Whatever follows the time is checked on its own below.
|
||||
// No end anchor on purpose: a greedy tail in front of '$' makes a long run of
|
||||
// digits cost quadratic time, and the stamp is text any upload can set.
|
||||
const EXIF_STAMP = /^(\d{4})[:-](\d{1,2})[:-](\d{1,2})[ T](\d{1,2}):(\d{1,2}):(\d{1,2})(?:\.\d+)?/;
|
||||
// A stamp with nanoseconds and a zone is 36 characters. A longer one is no
|
||||
// stamp, and is not worth reading.
|
||||
const EXIF_STAMP_MAX = 64;
|
||||
// '+02:00' is what the spec asks for; '+0200', 'Z', 'UTC' and 'GMT' say the same thing.
|
||||
const EXIF_ZONE = /^(?:(Z|UTC|GMT)|([+-])(\d{2}):?(\d{2}))$/i;
|
||||
|
||||
/** An EXIF zone ('+02:00', an OffsetTime* tag or a stamp's own) in minutes east of UTC, or null when it is not one. */
|
||||
function exifZoneMinutes(value: unknown): number | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const m = EXIF_ZONE.exec(value.trim());
|
||||
if (!m) return null;
|
||||
if (m[1]) return 0;
|
||||
const hours = Number(m[3]);
|
||||
const minutes = Number(m[4]);
|
||||
// Real zones run from -12:00 to +14:00; anything past that is a broken tag.
|
||||
if (hours > 14 || minutes > 59) return null;
|
||||
return (m[2] === '-' ? -1 : 1) * (hours * 60 + minutes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The instant an EXIF capture stamp names, as an ISO string, or null.
|
||||
*
|
||||
* EXIF stores the photographer's wall clock ('2026:05:30 15:52:19') and, in a
|
||||
* separate tag, the zone it was read in ('+02:00'). Handing the file to exifr
|
||||
* with its defaults turns the stamp into a Date in the server's zone and never
|
||||
* looks at the offset, so a photo taken in France and uploaded to a server in
|
||||
* Chicago was stored seven hours late (#2512). This takes the raw stamp and
|
||||
* applies the first well-formed offset in `offsets`. A stamp that carries its
|
||||
* own zone ('... UTC', 'Z', '+02:00', the form exifr's reviver comment names)
|
||||
* needs none of them: that zone is the most specific answer there is.
|
||||
*
|
||||
* Without one, the server's zone is all there is to go on. That is what every
|
||||
* upload got before, and it stays right for a server in the photographer's zone.
|
||||
*
|
||||
* A stamp that is not a real moment (blank, the zeros a camera without a clock
|
||||
* writes, 30 February, 25 o'clock) is null, not whatever Date rolls it over to.
|
||||
*/
|
||||
export function exifCaptureInstant(stamp: unknown, offsets: unknown[]): string | null {
|
||||
if (typeof stamp !== 'string') return null;
|
||||
const text = stamp.trim();
|
||||
if (text.length > EXIF_STAMP_MAX) return null;
|
||||
const m = EXIF_STAMP.exec(text);
|
||||
if (!m) return null;
|
||||
const suffix = text.slice(m[0].length).trim();
|
||||
// After the time there is either nothing or a zone; anything else ('CEST',
|
||||
// '+2', 'later') is not a stamp. A zone that is out of range leaves the
|
||||
// decision to the tags.
|
||||
if (suffix && !EXIF_ZONE.test(suffix)) return null;
|
||||
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
||||
const wall = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
||||
// Date.UTC quietly rolls an impossible part into the next one (and reads
|
||||
// years below 100 as 19xx), so the parts have to survive the round trip. It
|
||||
// is checked on the UTC reading so a local DST gap does not count as invalid.
|
||||
if (wall.getUTCFullYear() !== year || wall.getUTCMonth() !== month - 1 || wall.getUTCDate() !== day
|
||||
|| wall.getUTCHours() !== hour || wall.getUTCMinutes() !== minute || wall.getUTCSeconds() !== second) {
|
||||
return null;
|
||||
}
|
||||
const offset = exifZoneMinutes(suffix) ?? offsets.map(exifZoneMinutes).find(o => o !== null);
|
||||
const instant = offset == null
|
||||
? new Date(year, month - 1, day, hour, minute, second)
|
||||
: new Date(wall.getTime() - offset * 60000);
|
||||
return instant.toISOString();
|
||||
}
|
||||
|
||||
|
||||
export type AssetInfo = {
|
||||
id: string;
|
||||
takenAt: string | null;
|
||||
|
||||
@@ -3,6 +3,60 @@ import exifr from 'exifr';
|
||||
import { PhotoResolverService } from './photo-resolver.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
|
||||
import { exifCaptureInstant } from './memories.helpers';
|
||||
|
||||
/**
|
||||
* What readCapture asks exifr for.
|
||||
*
|
||||
* `latitude` and `longitude` are not tags. exifr works them out from the four
|
||||
* GPS tags below, and only when it has read those. A pick of just the two
|
||||
* derived names reads no GPS at all, which is how every upload used to lose
|
||||
* its location (#2512).
|
||||
*/
|
||||
const CAPTURE_TAGS = [
|
||||
'DateTimeOriginal',
|
||||
'CreateDate',
|
||||
'OffsetTimeOriginal',
|
||||
'OffsetTimeDigitized',
|
||||
'OffsetTime',
|
||||
'GPSLatitude',
|
||||
'GPSLatitudeRef',
|
||||
'GPSLongitude',
|
||||
'GPSLongitudeRef',
|
||||
] as const;
|
||||
|
||||
/** When and where an uploaded file says it was taken. */
|
||||
type LocalCapture = { takenAt: string | null; lat: number | null; lng: number | null };
|
||||
|
||||
type Exif = Partial<Record<(typeof CAPTURE_TAGS)[number] | 'latitude' | 'longitude', unknown>>;
|
||||
|
||||
function coordinate(value: unknown, limit: number): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= limit ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture time and place in a file's EXIF, or null when it names neither.
|
||||
* Throws for a file exifr cannot read at all.
|
||||
*/
|
||||
async function readCapture(abs: string): Promise<LocalCapture | null> {
|
||||
// Raw values on purpose: see exifCaptureInstant for why the stamps are not
|
||||
// left to exifr's reviver.
|
||||
const parsed = (await exifr.parse(abs, { pick: [...CAPTURE_TAGS], reviveValues: false })) as Exif | undefined;
|
||||
if (!parsed) return null;
|
||||
|
||||
// Each stamp has an offset tag of its own. The others stand in for a missing
|
||||
// one: a camera writes all three from the same clock.
|
||||
const { OffsetTimeOriginal: original, OffsetTimeDigitized: digitized, OffsetTime: modified } = parsed;
|
||||
const takenAt = exifCaptureInstant(parsed.DateTimeOriginal, [original, digitized, modified])
|
||||
?? exifCaptureInstant(parsed.CreateDate, [digitized, original, modified]);
|
||||
const lat = coordinate(parsed.latitude, 90);
|
||||
const lng = coordinate(parsed.longitude, 180);
|
||||
// A receiver without a fix can write zeros into the GPS tags instead of
|
||||
// leaving them out. Taken at its word, 0,0 pins the photo to open sea in the
|
||||
// Gulf of Guinea on the journey map, so it counts as no location.
|
||||
const hasPair = lat != null && lng != null && !(lat === 0 && lng === 0);
|
||||
return takenAt || hasPair ? { takenAt, lat: hasPair ? lat : null, lng: hasPair ? lng : null } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider lookups one user keeps in flight at once, across every run of theirs.
|
||||
@@ -167,9 +221,7 @@ export class PhotoCaptureBackfillService {
|
||||
* here already stripped. Nothing to read is the expected outcome far more often
|
||||
* than not — hence no logging on the empty case.
|
||||
*/
|
||||
private async readLocalExif(
|
||||
filePath?: string | null,
|
||||
): Promise<{ takenAt: string | null; lat: number | null; lng: number | null } | null> {
|
||||
private async readLocalExif(filePath?: string | null): Promise<LocalCapture | null> {
|
||||
if (!filePath) return null;
|
||||
// photos.file_path is uploads-relative 'journey/<name>' by every writer;
|
||||
// anything else reads as a miss (same rule as photo-resolver). Central key
|
||||
@@ -178,25 +230,12 @@ export class PhotoCaptureBackfillService {
|
||||
if (!filePath.startsWith('journey/')) return null;
|
||||
const name = filePath.slice('journey/'.length);
|
||||
|
||||
type Exif = { DateTimeOriginal?: Date; CreateDate?: Date; latitude?: number; longitude?: number };
|
||||
let parsed: Exif | null;
|
||||
try {
|
||||
parsed = await this.storage.withLocalFile('journey', name, async abs =>
|
||||
(await exifr.parse(abs, {
|
||||
pick: ['DateTimeOriginal', 'CreateDate', 'latitude', 'longitude'],
|
||||
})) as Exif | null,
|
||||
);
|
||||
return await this.storage.withLocalFile('journey', name, readCapture);
|
||||
} catch {
|
||||
// A vanished object, an invalid key, not an image, a truncated upload,
|
||||
// a video — none of it is an error here.
|
||||
return null;
|
||||
}
|
||||
if (!parsed) return null;
|
||||
|
||||
const taken = parsed.DateTimeOriginal ?? parsed.CreateDate ?? null;
|
||||
const takenAt = taken instanceof Date && !Number.isNaN(taken.getTime()) ? taken.toISOString() : null;
|
||||
const lat = typeof parsed.latitude === 'number' ? parsed.latitude : null;
|
||||
const lng = typeof parsed.longitude === 'number' ? parsed.longitude : null;
|
||||
return takenAt || lat != null ? { takenAt, lat, lng } : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,9 +253,10 @@ export class StorageService {
|
||||
}
|
||||
|
||||
const tmp = path.join(this.tempDir(), randomUUID());
|
||||
const { stream } = await driver.getStream(key);
|
||||
await pipeline(stream, fs.createWriteStream(tmp));
|
||||
try {
|
||||
const { stream } = await driver.getStream(key);
|
||||
// Inside the try, so a download cut short does not leave its part behind.
|
||||
await pipeline(stream, fs.createWriteStream(tmp));
|
||||
return await fn(tmp);
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { force: true });
|
||||
|
||||
Reference in New Issue
Block a user