Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m24s

`Photo` gains:

- `savePath` and `isLocal`: synchronous, disk only. Where `lib.backup()` writes the original under the library's `downloadDirectory`, and whether all of it is there; a copy only in the cache does not count.
- `content()` and `exif()`: async, may download. `exif()` reads the common EXIF fields of a JPEG and returns `{}` for anything else.
- The getters `modifiedAt` and `hash`, also on `PhotoRecord`, and `year`.

For a live photo the backup has not stored yet, `savePath` carries the title's extension, and the image may be stored under a different one. The JPEG EXIF scan moved to `src/exif.ts`. The exported `PhotoContent` interface gains `savePath` and `isLocal`.

Judgement call: `iso` is read only when the file stores it as a single number.

Model: opus-5-5
This commit was merged in pull request #142.
This commit is contained in:
2026-10-01 17:58:23 +02:00
parent e6825abcdb
commit 2b598d3622
12 changed files with 566 additions and 86 deletions
+145
View File
@@ -0,0 +1,145 @@
// EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block
// it finds; `Photo.exif()` returns the common fields picked from it here.
import exifReader from "exif-reader";
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
// data, starting at the "Exif\0\0" header) when there is one, nothing when the
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
// malformed. Each segment length is checked against the bytes that remain and
// each step moves forward by at least 4 bytes, so the scan ends on any input.
export const extractExifFromJpeg = (
buf: Uint8Array,
): { exif?: Buffer; error?: string } => {
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
let offset = 2;
while (offset < buf.length) {
if (offset + 2 > buf.length)
return { error: `truncated segment marker at byte ${offset}` };
if (buf[offset] !== 0xff)
return { error: `no segment marker at byte ${offset}` };
const marker = buf[offset + 1]!;
if (marker === 0xda) return {}; // start of scan, no more markers
if (offset + 4 > buf.length)
return { error: `truncated segment length at byte ${offset}` };
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
// The length counts its own two bytes, so anything under 2 is invalid.
if (len < 2)
return {
error: `segment length ${len} at byte ${offset} is too small`,
};
if (offset + 2 + len > buf.length)
return {
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
};
if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
// the six-byte header, so the segment is not EXIF; below 6 the
// bytes compared would also lie past the segment.
if (
len >= 8 &&
buf[offset + 4] === 0x45 &&
buf[offset + 5] === 0x78 &&
buf[offset + 6] === 0x69 &&
buf[offset + 7] === 0x66
) {
return {
exif: Buffer.from(
buf.buffer,
buf.byteOffset + offset + 4,
len - 2,
),
};
}
}
offset += 2 + len;
}
return { error: "file ends before the image data" };
};
// 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 exif-reader reads that text 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;
}
const asString = (v: unknown): string | undefined =>
typeof v === "string" && v.length > 0 ? v : undefined;
const asNumber = (v: unknown): number | undefined =>
typeof v === "number" && Number.isFinite(v) ? v : undefined;
const asDate = (v: unknown): Date | undefined =>
v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined;
// EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds.
// This is them as decimal degrees, negated when `negative`.
const asDegrees = (v: unknown, negative: boolean): number | undefined => {
if (!Array.isArray(v) || v.length !== 3) return undefined;
const [d, m, s] = v.map(asNumber);
if (d === undefined || m === undefined || s === undefined) return undefined;
const degrees = d + m / 60 + s / 3600;
return negative ? -degrees : degrees;
};
// The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG,
// have no EXIF block, or exif-reader cannot parse it.
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => {
const { exif } = extractExifFromJpeg(bytes);
if (exif === undefined) return {};
let tags: ReturnType<typeof exifReader>;
try {
tags = exifReader(exif);
} catch {
return {};
}
const image = tags.Image ?? {};
const photo = tags.Photo ?? {};
const gps = tags.GPSInfo ?? {};
const altitude = asNumber(gps.GPSAltitude);
const fields: PhotoExif = {
make: asString(image.Make),
model: asString(image.Model),
lensModel: asString(photo.LensModel),
dateTimeOriginal: asDate(photo.DateTimeOriginal),
offsetTimeOriginal: asString(photo.OffsetTimeOriginal),
exposureTime: asNumber(photo.ExposureTime),
fNumber: asNumber(photo.FNumber),
iso: asNumber(photo.ISOSpeedRatings),
focalLength: asNumber(photo.FocalLength),
orientation: asNumber(image.Orientation),
gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"),
gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"),
// A GPSAltitudeRef of 1 means the altitude is below sea level.
gpsAltitude:
altitude !== undefined && gps.GPSAltitudeRef === 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;
};