check / check (push) Successful in 1m18s
A text tag whose value lies outside the file, which exifreader reads as "<faulty value>", no longer gives a make, model, lens model or offset field. Tests cover such a JPEG and the backup-metadata dump keying a tag exifreader has no name for as undefined-<tag number>. Model: opus-5-5
116 lines
4.8 KiB
TypeScript
116 lines
4.8 KiB
TypeScript
// 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-<tag number>`. 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 "<faulty value>" 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 !== "<faulty value>"
|
|
? 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;
|
|
};
|