// EXIF in an original's bytes, read with exifreader, which reads it from JPEG, // HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports. // `backup-metadata --exif` records every EXIF tag it finds except the // thumbnail's; `Photo.exif()` returns the common fields picked from them here. import ExifReader, { type ExpandedTags } from "exifreader"; // The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from // them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`). // Undefined when exifreader cannot read the file at all, such as a video. An // EXIF block it finds but reads no tag from comes back as an empty `exif`. // `exif` holds every tag except the thumbnail's; a tag exifreader has no name // for is keyed `undefined-`. Each tag's `computed` holds its value // as a string or number, or as an array of them for a tag with several values, // such as `GPSLatitude`'s `[40, 26, 46]`. A fraction with a zero denominator // computes to null. export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { try { return ExifReader.loadView( new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), { expanded: true, computed: true, includeOffsets: true, includeUnknown: true, includeTags: { exif: true, gps: true }, }, ); } catch { return undefined; } }; // The common EXIF fields of an original. Each is absent when the file lacks it. export interface PhotoExif { make?: string; model?: string; lensModel?: string; // When the photo was taken, by the camera's clock. EXIF writes this as text // with no time zone, and it is read as if it were UTC: the Date's UTC // fields are the clock reading, which is the moment it was taken only when // the clock was set to UTC. dateTimeOriginal?: Date; // The camera clock's offset from UTC, such as "+02:00". offsetTimeOriginal?: string; // Seconds. exposureTime?: number; fNumber?: number; iso?: number; // Millimetres. focalLength?: number; // The EXIF orientation code, 1 to 8. orientation?: number; // Decimal degrees, negative south of the equator and west of Greenwich. gpsLatitude?: number; gpsLongitude?: number; // Metres, negative below sea level. gpsAltitude?: number; } // exifreader gives "" for a tag whose value lies outside the // file; that tag is left out like one the file lacks. const asString = (v: unknown): string | undefined => typeof v === "string" && v.length > 0 && v !== "" ? v : undefined; const asNumber = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined; // EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading // in a Date's UTC fields. const asDate = (v: unknown): Date | undefined => { const m = typeof v === "string" ? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v) : null; if (!m) return undefined; const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`); return Number.isNaN(date.getTime()) ? undefined : date; }; // The common fields of an original's EXIF: `{}` when the file has no EXIF, // exifreader cannot read its EXIF, or it is not an image exifreader reads. export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { const tags = readExifTags(bytes); const exif = tags?.exif; const gps = tags?.gps; const altitude = asNumber(exif?.GPSAltitude?.computed); const fields: PhotoExif = { make: asString(exif?.Make?.computed), model: asString(exif?.Model?.computed), lensModel: asString(exif?.LensModel?.computed), dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed), offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed), exposureTime: asNumber(exif?.ExposureTime?.computed), fNumber: asNumber(exif?.FNumber?.computed), // Only when the tag holds a single number, as most cameras write it. iso: asNumber(exif?.ISOSpeedRatings?.computed), focalLength: asNumber(exif?.FocalLength?.computed), orientation: asNumber(exif?.Orientation?.computed), gpsLatitude: asNumber(gps?.Latitude), gpsLongitude: asNumber(gps?.Longitude), // A GPSAltitudeRef of 1 means the altitude is below sea level. gpsAltitude: altitude !== undefined && exif?.GPSAltitudeRef?.value === 1 ? -altitude : altitude, }; // Leave out what the file lacks, so a missing field is absent rather than // present and undefined. return Object.fromEntries( Object.entries(fields).filter(([, v]) => v !== undefined), ) as PhotoExif; };