Files
quak/src/exif.ts
T
clawbot 6dd22bcb73
check / check (push) Waiting to run
exif() returns every EXIF tag in the file (closes #156)
`photo.exif()` returns `ExifTags`: every EXIF tag exifreader reads from the file, keyed by tag name, each with exifreader's `id`, `value`, `description` and `computed`. The embedded thumbnail's tags are under `Thumbnail`, without the thumbnail image. A file with no EXIF, or a video, gives `{}`.

The thirteen typed methods stay. Each picks its field from the tags `exif()` returns through `readPhotoExif`, which now takes the tags instead of the bytes. GPS latitude and longitude are worked out from their tags and reference tags, since exifreader's computed position is not among the tags.

Also closes #148.

Model: opus-5-5
2026-10-02 02:25:28 +00:00

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;
};