check / check (push) Failing after 59s
`photo.exif()` now returns every EXIF tag in the file, as the owner ruled, typed `ExifTags`: each tag keyed by name with `exifreader`'s `id`, `value`, `description` and `computed`. A tag with no name is keyed `undefined-` plus its number, and the embedded thumbnail's tags sit under `Thumbnail`. The thirteen typed methods stay, with the same names and types; each now picks its field from `exif()`'s tags. GPS latitude and longitude are worked out from the GPS tags and their reference tags, and a position with no reference tags gives neither. `backup-metadata --exif` output is unchanged. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
165 lines
6.6 KiB
TypeScript
165 lines
6.6 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 every tag, the thumbnail's included, and
|
|
// `Photo`'s typed methods return the common fields picked from them here.
|
|
|
|
import ExifReader, { type ExpandedTags } from "exifreader";
|
|
|
|
// The EXIF tags in `bytes` (`exif`), the embedded thumbnail's tags
|
|
// (`Thumbnail`), 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`. 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, thumbnail: true },
|
|
},
|
|
);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
// Every EXIF tag of an original, keyed by name, each as exifreader decodes it
|
|
// (see `readExifTags`). The embedded thumbnail's own tags are under
|
|
// `Thumbnail`, so its `Orientation` or `ImageWidth` cannot hide the main
|
|
// image's.
|
|
export type ExifTags = Omit<NonNullable<ExpandedTags["exif"]>, "Thumbnail"> & {
|
|
Thumbnail?: Omit<
|
|
NonNullable<ExpandedTags["Thumbnail"]>,
|
|
"type" | "image" | "base64"
|
|
>;
|
|
};
|
|
|
|
// Every EXIF tag in `bytes`: `{}` when the file has no EXIF, exifreader cannot
|
|
// read its EXIF, or it is not an image exifreader reads.
|
|
export const readAllExifTags = (bytes: Uint8Array): ExifTags => {
|
|
const tags = readExifTags(bytes);
|
|
if (!tags?.Thumbnail) return tags?.exif ?? {};
|
|
// exifreader puts the thumbnail's JPEG image beside its tags, as `type`,
|
|
// `image` and `base64`. The image is not a tag, so it is left out.
|
|
const {
|
|
type: _type,
|
|
image: _image,
|
|
base64: _base64,
|
|
...thumbnail
|
|
} = tags.Thumbnail;
|
|
return { ...tags.exif, Thumbnail: thumbnail };
|
|
};
|
|
|
|
// The common EXIF fields of an original, one for each of `Photo`'s typed
|
|
// methods. 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;
|
|
};
|
|
|
|
// GPSLatitude and GPSLongitude hold degrees, minutes and seconds, computed as
|
|
// three numbers. This is them in decimal degrees, negative when `ref`, the
|
|
// GPSLatitudeRef or GPSLongitudeRef tag, is `negativeRef` ("S" or "W").
|
|
// Without that tag the hemisphere is unknown, so it is undefined.
|
|
const asDegrees = (
|
|
dms: unknown,
|
|
ref: unknown,
|
|
negativeRef: string,
|
|
): number | undefined => {
|
|
if (!Array.isArray(dms) || ref === undefined) return undefined;
|
|
const [d, m, s] = dms.map(asNumber);
|
|
if (d === undefined || m === undefined || s === undefined) return undefined;
|
|
const degrees = d + m / 60 + s / 3600;
|
|
return ref === negativeRef ? -degrees : degrees;
|
|
};
|
|
|
|
// The common fields picked from an original's EXIF tags, `readAllExifTags`'s
|
|
// result: `{}` when there are none.
|
|
export const readPhotoExif = (tags: ExifTags): PhotoExif => {
|
|
const altitude = asNumber(tags.GPSAltitude?.computed);
|
|
const fields: PhotoExif = {
|
|
make: asString(tags.Make?.computed),
|
|
model: asString(tags.Model?.computed),
|
|
lensModel: asString(tags.LensModel?.computed),
|
|
dateTimeOriginal: asDate(tags.DateTimeOriginal?.computed),
|
|
offsetTimeOriginal: asString(tags.OffsetTimeOriginal?.computed),
|
|
exposureTime: asNumber(tags.ExposureTime?.computed),
|
|
fNumber: asNumber(tags.FNumber?.computed),
|
|
// Only when the tag holds a single number, as most cameras write it.
|
|
iso: asNumber(tags.ISOSpeedRatings?.computed),
|
|
focalLength: asNumber(tags.FocalLength?.computed),
|
|
orientation: asNumber(tags.Orientation?.computed),
|
|
gpsLatitude: asDegrees(
|
|
tags.GPSLatitude?.computed,
|
|
tags.GPSLatitudeRef?.computed,
|
|
"S",
|
|
),
|
|
gpsLongitude: asDegrees(
|
|
tags.GPSLongitude?.computed,
|
|
tags.GPSLongitudeRef?.computed,
|
|
"W",
|
|
),
|
|
// A GPSAltitudeRef of 1 means the altitude is below sea level.
|
|
gpsAltitude:
|
|
altitude !== undefined && tags.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;
|
|
};
|