exif(): read HEIF/HEIC originals with exifreader (closes #145)
check / check (push) Successful in 2m10s
check / check (push) Successful in 2m10s
`photo.exif()` and `quak backup-metadata --exif` now read EXIF through `exifreader`. HEIC/HEIF originals get EXIF, including a live photo's image, as do the other formats `exifreader` reads. It replaces `exif-reader` and the hand-written JPEG scan. `PhotoExif` is unchanged. The `backup-metadata` dump now holds `exifreader`'s tag output, with unnamed tags keyed `undefined-` plus their number. GPS altitude without a reference counts as above sea level. A latitude or longitude without its hemisphere tag, an unreadable text tag, and a date the parser rejects each give no field. Licence: `exifreader` is MPL-2.0, used unmodified. Model: opus-5-5
This commit was merged in pull request #147.
This commit is contained in:
+66
-96
@@ -1,60 +1,34 @@
|
||||
// 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.
|
||||
// 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 from "exif-reader";
|
||||
import ExifReader, { type ExpandedTags } from "exifreader";
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
return { error: "file ends before the image data" };
|
||||
};
|
||||
|
||||
// The common EXIF fields of an original. Each is absent when the file lacks it.
|
||||
@@ -63,9 +37,9 @@ export interface PhotoExif {
|
||||
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.
|
||||
// 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;
|
||||
@@ -84,56 +58,52 @@ export interface PhotoExif {
|
||||
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 : 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;
|
||||
|
||||
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;
|
||||
// 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 a JPEG's EXIF block: `{}` when the bytes are not a JPEG,
|
||||
// have no EXIF block, or exif-reader cannot parse it.
|
||||
// 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 { 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 tags = readExifTags(bytes);
|
||||
const exif = tags?.exif;
|
||||
const gps = tags?.gps;
|
||||
const altitude = asNumber(exif?.GPSAltitude?.computed);
|
||||
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"),
|
||||
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 && gps.GPSAltitudeRef === 1
|
||||
altitude !== undefined && exif?.GPSAltitudeRef?.value === 1
|
||||
? -altitude
|
||||
: altitude,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user