Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m24s
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:
+145
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user