From a13ec66bca59a0443c0b62751c32318eb13af6ba Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 18:22:00 +0000 Subject: [PATCH 1/5] exif(): read HEIF/HEIC originals with exifreader (closes #145) photo.exif() and backup-metadata --exif read EXIF with exifreader, which reads JPEG, HEIC/HEIF, AVIF, PNG and WebP, so a HEIC original or a live photo's HEIC image gets its EXIF. exifreader replaces exif-reader and the hand-written JPEG segment scan. PhotoExif is unchanged. The exif field of backup-metadata --exif is now exifreader's tag output, and exifRaw holds the whole EXIF block it could not read. Tests use a real 933-byte HEIC fixture, test/exif.heic, whose recipe is in test/exif-heic.ts. Model: opus-5-5 --- README.md | 18 ++- TODO.md | 8 ++ package.json | 2 +- src/exif.ts | 154 +++++++++--------------- src/library/read.ts | 7 +- src/metadata-backup.ts | 30 ++--- test/cli/commands.test.ts | 26 +++++ test/cli/metadata-exif.test.ts | 169 +++++++++++++++------------ test/exif-heic.ts | 27 +++++ test/exif.heic | Bin 0 -> 933 bytes test/library/content-library.test.ts | 57 ++++++++- yarn.lock | 15 ++- 12 files changed, 307 insertions(+), 206 deletions(-) create mode 100644 test/exif-heic.ts create mode 100644 test/exif.heic diff --git a/README.md b/README.md index a75f15b..3ab7ff2 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ quak/ backup.ts resilient full-account backup with dedup metadata-backup.ts backup-metadata: the metadata quak keeps, as JSON - exif.ts EXIF read from a JPEG's bytes + exif.ts EXIF read from an image's bytes with exifreader mldata-fetch.ts fetch + decrypt per-file ML data filename.ts safe file names from server metadata errors.ts error types shared across layers @@ -495,8 +495,15 @@ Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With `--out PATH`, the image is written to `PATH` and the video beside it, with `PATH`'s name and the video's extension; a `PATH` with the video's extension is refused. `backup-metadata --exif` (alias `--all`) additionally fetches each -file's original through the cache and records its XMP metadata and, for a JPEG, -its EXIF metadata and dimensions. `collections`, `files`, `backup`, +file's original through the cache and records, from it or a live photo's image, +its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is +read with [exifreader](https://github.com/mattiasw/ExifReader) from any image +format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's +`exif` field is exifreader's EXIF tag output: each tag by name, with its +`value`, `description` and `computed` value. Dumps written before 2026-10-01 +hold `exif-reader`'s `Image`, `Photo` and `GPSInfo` groups there instead. An +EXIF block exifreader finds but reads no tag from is recorded, base64, as +`exifRaw`, with the reason in `exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take `--json` for machine-readable output. @@ -720,8 +727,9 @@ Four async methods may download: each absent when the file lacks it. GPS values are signed decimal degrees and metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from - UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video - gives `{}` without being downloaded. + UTC. EXIF is read from any image format exifreader reads: JPEG, HEIC/HEIF, + AVIF, PNG and WebP, a live photo's image included. Any other original gives + `{}`, and a video gives `{}` without being downloaded. They serve from the on-disk content cache when the bytes are present and otherwise fetch through the pools; `original()`, `content()` and `exif()` also diff --git a/TODO.md b/TODO.md index 4c188b5..b342066 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ declares one. # Completed Steps +- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from + HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and + the other image formats `exifreader` reads (issue 145). `exifreader` replaces + `exif-reader` and the JPEG segment scan; `PhotoExif` is unchanged. The `exif` + field of `backup-metadata --exif` is now exifreader's tag output, and + `exifRaw` holds the whole EXIF block it could not read. The tests use a real + HEIC, `test/exif.heic`. + - 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`, `modifiedAt`, `hash` and `year` (issue 141). `savePath` is where `lib.backup()` writes the original under the library's download directory; for diff --git a/package.json b/package.json index f1a1d6f..779a239 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@inquirer/prompts": "8.5.2", "commander": "14.0.3", "env-paths": "4.0.0", - "exif-reader": "2.0.3", + "exifreader": "4.46.0", "fast-srp-hap": "2.0.4", "fflate": "0.8.3", "jpeg-js": "0.4.4", diff --git a/src/exif.ts b/src/exif.ts index 778cbfa..9d90df7 100644 --- a/src/exif.ts +++ b/src/exif.ts @@ -1,60 +1,29 @@ -// 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; `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`. +// Each tag's `computed` holds its value as a plain string or number. +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, + 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 +32,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; @@ -90,52 +59,39 @@ const asString = (v: unknown): string | 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; - 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 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, + 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), + gpsAltitude: asNumber(gps?.Altitude), }; // Leave out what the file lacks, so a missing field is absent rather than // present and undefined. diff --git a/src/library/read.ts b/src/library/read.ts index a10731d..68a8dd2 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -130,9 +130,10 @@ export class Photo { } // The common EXIF fields of the original, read from `content()`, so this - // may download it. Only a JPEG's EXIF is read; any other file gives `{}`, - // and a video gives it without fetching anything. Like the other content - // methods, it throws when there is no content cache, video or not. + // may download it. EXIF is read from any image format exifreader reads, + // JPEG and HEIC/HEIF among them; any other file gives `{}`, and a video + // gives it without fetching anything. Like the other content methods, it + // throws when there is no content cache, video or not. async exif(opts?: ContentOptions): Promise { this.cacheOrThrow(); if (this.rec.fileType === "video") return {}; diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 420fb9e..d453bf8 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -1,9 +1,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import * as jpeg from "jpeg-js"; -import exifReader from "exif-reader"; import type { Client } from "./client.js"; -import { extractExifFromJpeg } from "./exif.js"; +import { readExifTags } from "./exif.js"; import type { Library, Photo } from "./library/index.js"; import { sanitizeFileName } from "./filename.js"; import { @@ -20,9 +19,10 @@ export interface MetadataBackupOptions { onProgress?: ProgressCallback; } -// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment -// is malformed or cannot be parsed, the record carries the reason in -// `exifError`. +// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags +// exifreader returns, from any image format it reads. When it finds an EXIF +// block but reads no tag from it, the record keeps the block's bytes, base64, +// in `exifRaw`, with the reason in `exifError`. export const extractImageMetadata = ( fileBytes: Uint8Array, ): Record | undefined => { @@ -39,19 +39,21 @@ export const extractImageMetadata = ( result.height = decoded.height; } catch { // Not every original is a JPEG (PNG, HEIC, video), so a failed decode - // is expected and only means no dimensions; a malformed JPEG is still + // is expected and only means no dimensions; unreadable EXIF is still // reported below through `exifError`. } - const { exif, error } = extractExifFromJpeg(fileBytes); - if (error) result.exifError = error; - if (exif) { - try { - result.exif = exifReader(exif); - } catch (err) { - result.exifRaw = exif.toString("base64"); - result.exifError = err instanceof Error ? err.message : String(err); + const tags = readExifTags(fileBytes); + if (tags?.exif && Object.keys(tags.exif).length > 0) { + result.exif = tags.exif; + } else if (tags?.exif) { + const block = tags.metadataRange?.blocks.find((b) => b.type === "exif"); + if (block) { + result.exifRaw = Buffer.from( + fileBytes.subarray(block.start, block.end), + ).toString("base64"); } + result.exifError = "no tag could be read from the EXIF block"; } // Extract XMP (look for "http://ns.adobe.com/xap" in the bytes) diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 5b441e5..ff939ff 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -56,6 +56,7 @@ import type { ContentSource } from "../../src/library/content.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { init, toBase64 } from "../../src/crypto/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { asLivePhoto, cdnSource, @@ -653,6 +654,31 @@ describe("a live photo", () => { height: 4, }); }); + + it("backup-metadata --exif records the EXIF of a HEIC image", async () => { + const dir = join(root, "dump"); + + expect( + await backupMetadataCommand( + context(await livePhotoClient(HEIC_WITH_EXIF)), + dir, + { exif: true }, + ), + ).toBe(0); + + const record = JSON.parse( + readFileSync( + join(dir, "collections", "1-Vacation", "300.json"), + "utf-8", + ), + ); + expect(record.imageMetadata.exifError).toBeUndefined(); + expect(record.imageMetadata.exif).toMatchObject({ + Make: { value: ["Canon"] }, + Model: { value: ["EOS R5"] }, + DateTimeOriginal: { value: ["2021:07:15 14:30:00"] }, + }); + }); }); describe("backup", () => { diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 870826c..dffe9a6 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -1,19 +1,24 @@ /** - * Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`. + * Tests for reading EXIF (`src/exif.ts`) and the image metadata + * `quak backup-metadata --exif` records. * - * The originals come from users' libraries, so a truncated or corrupt JPEG - * must neither hang the scan nor throw out of it, and a malformed file must be - * told apart from one that simply has no EXIF: the record carries the reason in - * `exifError`. Each input below is a short hand-built byte array. + * The originals come from users' libraries, so a truncated or corrupt file + * must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`, + * and `backup-metadata` tells an EXIF block it cannot read apart from a file + * that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below + * is a short hand-built byte array; the HEIC is a real file. */ import { describe, expect, it } from "vitest"; -import { extractExifFromJpeg } from "../../src/exif.js"; +import { readPhotoExif } from "../../src/exif.js"; import { extractImageMetadata } from "../../src/metadata-backup.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; const SOI = [0xff, 0xd8]; // start of image -const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops +const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0" +const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; +const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00]; // A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6. const TIFF_ORIENTATION_6 = [ @@ -31,74 +36,66 @@ const app1 = (data: number[]): number[] => { const bytes = (...parts: number[][]): Uint8Array => new Uint8Array(parts.flat()); -describe("extractExifFromJpeg", () => { - it("returns the EXIF segment of a valid JPEG", () => { +describe("readPhotoExif", () => { + it("reads the common fields of a valid JPEG", () => { const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6]; - const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS)); - expect(scan.error).toBeUndefined(); - expect([...scan.exif!]).toEqual(data); + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + orientation: 6, + }); }); - it("returns nothing for a file that is not a JPEG", () => { - const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - expect(extractExifFromJpeg(png)).toEqual({}); - }); - - it("returns nothing for a JPEG without EXIF", () => { - const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; - expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({}); - }); - - it("ignores an APP1 segment too short to hold the Exif header", () => { - // A length under 8 cannot hold the six-byte "Exif\0\0" header, so the - // segment is not EXIF. This one has length 7 and holds only "Exif\0", - // which the old code, lacking the length check, returned as EXIF. - const short = app1(EXIF_HEADER.slice(0, 5)); - expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({}); - }); - - it("accepts an APP1 segment of length 8 holding just the Exif header", () => { - const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS)); - expect(scan.error).toBeUndefined(); - expect([...scan.exif!]).toEqual(EXIF_HEADER); - }); - - it("reports a JPEG truncated inside a segment header", () => { - const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00])); - expect(scan.exif).toBeUndefined(); - expect(scan.error).toMatch(/truncated segment length/); - }); - - it("reports a JPEG that ends before the image data", () => { - const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; - const scan = extractExifFromJpeg(bytes(SOI, app0)); - expect(scan.error).toMatch(/ends before the image data/); - }); - - it("stops on a zero-length segment instead of looping", () => { - // A length of 0 would otherwise step the scan by 2 bytes at a time - // through the rest of the file, reading garbage as markers. - const zero = [0xff, 0xe0, 0x00, 0x00]; - const scan = extractExifFromJpeg( - bytes(SOI, zero, zero, zero, zero, SOS), - ); - expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/); - }); - - it("stops on a segment length of 1", () => { - const scan = extractExifFromJpeg( - bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS), - ); - expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/); - }); - - it("reports a segment length that runs past the end of the file", () => { + it.each([ + [ + "a file that is not an image", + new TextEncoder().encode("just some text, not an image"), + ], + [ + "a PNG without EXIF", + bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ], + ["a JPEG without EXIF", bytes(SOI, APP0, SOS)], + // A length under 8 cannot hold the six-byte "Exif\0\0" header. This one + // has length 7 and holds only "Exif\0", so a read past its end would + // take the next segment's bytes as EXIF. + [ + "an APP1 segment too short to hold the Exif header", + bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS), + ], + [ + "an APP1 segment holding just the Exif header", + bytes(SOI, app1(EXIF_HEADER), SOS), + ], + [ + "a JPEG truncated inside a segment header", + bytes(SOI, [0xff, 0xe1, 0x00]), + ], + ["a JPEG that ends before the image data", bytes(SOI, APP0)], + // A length of 0 would step a scan by 2 bytes at a time through the + // rest of the file, reading garbage as markers. + [ + "a zero-length segment", + bytes( + SOI, + ZERO_LENGTH_APP0, + ZERO_LENGTH_APP0, + ZERO_LENGTH_APP0, + ZERO_LENGTH_APP0, + SOS, + ), + ], + ["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)], // APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows. - const scan = extractExifFromJpeg( + [ + "a segment length that runs past the end of the file", bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), - ); - expect(scan.exif).toBeUndefined(); - expect(scan.error).toMatch(/runs past the end of the file/); + ], + // "XX" where the TIFF byte order belongs. + [ + "an EXIF block that cannot be parsed", + bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS), + ], + ])("returns no fields for %s", (_, input) => { + expect(readPhotoExif(input)).toStrictEqual({}); }); }); @@ -108,10 +105,22 @@ describe("extractImageMetadata", () => { bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS), ); expect(meta?.exifError).toBeUndefined(); - expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } }); + expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } }); }); - it("returns nothing for a file that is not a JPEG", () => { + it("parses EXIF from a HEIC", () => { + const meta = extractImageMetadata(HEIC_WITH_EXIF); + expect(meta?.exifError).toBeUndefined(); + expect(meta?.exif).toMatchObject({ + Make: { value: ["Canon"] }, + Model: { value: ["EOS R5"] }, + DateTimeOriginal: { value: ["2021:07:15 14:30:00"] }, + Orientation: { value: 6 }, + GPSLatitudeRef: { value: ["N"] }, + }); + }); + + it("returns nothing for a file that is not an image", () => { const text = new TextEncoder().encode("just some text, not an image"); expect(extractImageMetadata(text)).toBeUndefined(); }); @@ -121,14 +130,20 @@ describe("extractImageMetadata", () => { bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), ); expect(meta?.exif).toBeUndefined(); - expect(meta?.exifError).toMatch(/runs past the end of the file/); + expect(meta?.exifError).toBe( + "no tag could be read from the EXIF block", + ); }); it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => { - const data = [...EXIF_HEADER, 0x58, 0x58]; - const meta = extractImageMetadata(bytes(SOI, app1(data), SOS)); + // The raw bytes are the whole EXIF block as exifreader finds it: for a + // JPEG, the APP1 segment, marker and length included. + const segment = app1([...EXIF_HEADER, 0x58, 0x58]); + const meta = extractImageMetadata(bytes(SOI, segment, SOS)); expect(meta?.exif).toBeUndefined(); - expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64")); - expect(meta?.exifError).toEqual(expect.any(String)); + expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64")); + expect(meta?.exifError).toBe( + "no tag could be read from the EXIF block", + ); }); }); diff --git a/test/exif-heic.ts b/test/exif-heic.ts new file mode 100644 index 0000000..ad3acc6 --- /dev/null +++ b/test/exif-heic.ts @@ -0,0 +1,27 @@ +/** + * `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same + * values as the hand-built JPEG in `library/content-library.test.ts`, for the + * tests of `exif()` and `backup-metadata --exif`. + * + * It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3), + * with libheif 1.23.0 and exiftool 13.55: + * + * apk add libheif-tools exiftool imagemagick + * magick -size 64x64 gradient:red-blue -depth 8 in.png + * heif-enc -q 30 -o exif.heic in.png + * exiftool -overwrite_original \ + * -Make=Canon -Model="EOS R5" -LensModel="RF50mm F1.8 STM" \ + * -DateTimeOriginal="2021:07:15 14:30:00" -OffsetTimeOriginal="+02:00" \ + * -ExposureTime=1/250 -FNumber=2.8 -ISO=400 -FocalLength=50 \ + * -Orientation#=6 \ + * -GPSLatitude="40 26 46" -GPSLatitudeRef=N \ + * -GPSLongitude="79 58 56" -GPSLongitudeRef=W \ + * -GPSAltitude=12.5 -GPSAltitudeRef#=1 \ + * exif.heic + */ + +import { readFileSync } from "node:fs"; + +export const HEIC_WITH_EXIF = new Uint8Array( + readFileSync(new URL("exif.heic", import.meta.url)), +); diff --git a/test/exif.heic b/test/exif.heic new file mode 100644 index 0000000000000000000000000000000000000000..b6b460f1f9afbdd6a574ea76453aac111c22bbd7 GIT binary patch literal 933 zcmYjN&ubG=5T4mh(@<(!O|>>ije@aSD7$HjA*W!QLJzgjic;`k+$7{dHd)dj5ie;k zR;UmKMT9~Rf;Y{jhbs8%`Wnvs@r5HLmIKj`dtq}FGBMS8Qy_^ zM3_>lfss|swO>on^kc{aL7fBqgE5Dn-zapPo$`?=GR0-aiN;2V9dWpYT4UuO5}f0?b= zYR{A1xeG5VTC_hT#h>H|Wp1IqcZNUI+GjJE6tSx1TE!|iARnP%6Vi}caSA}OjPH1S zLy5rXMZ?Oma}vRrIIybe0kC~LpOfVWy=BbLW4A;h3i5!gh=TBIJm~UX@Ed$g{sS$L z(IP&_uI?#9Kj=jH^>ba$al|y8t5hqLU08}OP9f}DdqzD#sr|aPdB}Iy&wKqC%fct~ z0!Cso(bC=dLx_L2j&|R|!G~Cj)Uc-0qrPq*4sWs7r_&*No^ { await lib.close(); }); - it("returns no EXIF fields for an original that is not a JPEG", async () => { + // What exif() returns for HEIC_WITH_EXIF, which holds the same values as + // JPEG_WITH_EXIF. + const heicFields: PhotoExif = { + make: "Canon", + model: "EOS R5", + lensModel: "RF50mm F1.8 STM", + dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)), + offsetTimeOriginal: "+02:00", + exposureTime: 1 / 250, + fNumber: 2.8, + iso: 400, + focalLength: 50, + orientation: 6, + gpsLatitude: 40 + 26 / 60 + 46 / 3600, + gpsLongitude: -(79 + 58 / 60 + 56 / 3600), + gpsAltitude: -12.5, + }; + + it("reads the same common EXIF fields from a HEIC original", async () => { + const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual( + heicFields, + ); + await lib.close(); + }); + + it("reads the EXIF of a live photo whose image is a HEIC", async () => { + const { file: live, body } = await asLivePhoto( + file(1, 1), + livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }), + livePhotoHash(HEIC_WITH_EXIF, VIDEO), + ); + const lib = await open({ + client: new FilesClient([live]), + contentSource: cdnSource(new Map([[1, body]])), + }); + expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual( + heicFields, + ); + await lib.close(); + }); + + it("returns no EXIF fields for an original that is not an image", async () => { const lib = await open(); expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); await lib.close(); diff --git a/yarn.lock b/yarn.lock index 7759c2d..4386074 100644 --- a/yarn.lock +++ b/yarn.lock @@ -702,6 +702,11 @@ loupe "^3.1.2" tinyrainbow "^1.2.0" +"@xmldom/xmldom@^0.9.10": + version "0.9.12" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz#1f84c07cb95ccf28202299f77b5fd7fc257151e8" + integrity sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1002,10 +1007,12 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -exif-reader@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d" - integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A== +exifreader@4.46.0: + version "4.46.0" + resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f" + integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q== + optionalDependencies: + "@xmldom/xmldom" "^0.9.10" expect-type@^1.1.0: version "1.3.0" -- 2.54.0 From 9c0c7c9489a8d8ebbfc4d5208866e14c38aa7117 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 19:14:59 +0000 Subject: [PATCH 2/5] Test the unset-clock EXIF date; drop the old dump layout from README Add a readPhotoExif test: EXIF whose DateTimeOriginal is 0000:00:00 00:00:00, as a camera with an unset clock writes it, gives no dateTimeOriginal while its other fields are still read. Remove the README sentence describing the backup's exif field in dumps from an earlier version. Model: opus-5-5 --- README.md | 10 ++++------ test/cli/metadata-exif.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3ab7ff2..66d60ea 100644 --- a/README.md +++ b/README.md @@ -500,12 +500,10 @@ its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is read with [exifreader](https://github.com/mattiasw/ExifReader) from any image format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's `exif` field is exifreader's EXIF tag output: each tag by name, with its -`value`, `description` and `computed` value. Dumps written before 2026-10-01 -hold `exif-reader`'s `Image`, `Photo` and `GPSInfo` groups there instead. An -EXIF block exifreader finds but reads no tag from is recorded, base64, as -`exifRaw`, with the reason in `exifError`. `collections`, `files`, `backup`, -`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take -`--json` for machine-readable output. +`value`, `description` and `computed` value. An EXIF block exifreader finds but +reads no tag from is recorded, base64, as `exifRaw`, with the reason in +`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails` +and `helper fix-missing-thumbnails` take `--json` for machine-readable output. `backup-metadata` fetches ML data in requests of up to 200 files. When a request fails, the error is logged, each of its files is written with the reason in an diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index dffe9a6..2deda57 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -27,6 +27,25 @@ const TIFF_ORIENTATION_6 = [ 0x00, 0x00, ]; +// A big-endian TIFF block holding Orientation 6 and DateTimeOriginal +// "0000:00:00 00:00:00", which a camera with an unset clock writes. +const TIFF_UNSET_DATE = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: two entries, then no next IFD. + ...[0x00, 0x02], + // Orientation (0x0112), SHORT, 6. + ...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00], + // The Exif IFD's offset (0x8769), LONG, 38. + ...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26], + ...[0x00, 0x00, 0x00, 0x00], + // The Exif IFD, at 38: one entry, then no next IFD. + ...[0x00, 0x01], + // DateTimeOriginal (0x9003), 20 ASCII bytes at 56. + ...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38], + ...[0x00, 0x00, 0x00, 0x00], + ...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56 +]; + // An APP1 segment whose length field matches its data. const app1 = (data: number[]): number[] => { const len = data.length + 2; @@ -44,6 +63,13 @@ describe("readPhotoExif", () => { }); }); + it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => { + const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE]; + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + orientation: 6, + }); + }); + it.each([ [ "a file that is not an image", -- 2.54.0 From e623fd2015f56c329ef9ec1fbfcfaf391dd9dbf5 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 19:35:52 +0000 Subject: [PATCH 3/5] Read GPSAltitude without its reference tag; keep unnamed EXIF tags exif() takes the altitude from the GPSAltitude tag and negates it only when GPSAltitudeRef is 1, so a file with GPSAltitude and no GPSAltitudeRef keeps its altitude, above sea level. A test covers that file. backup-metadata --exif keeps the EXIF tags exifreader has no name for. The comments in src/exif.ts say the thumbnail's tags are not kept and that a tag with several values computes to an array. Model: opus-5-5 --- src/exif.ts | 18 ++++++++++++++---- test/cli/metadata-exif.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/exif.ts b/src/exif.ts index 9d90df7..224631c 100644 --- a/src/exif.ts +++ b/src/exif.ts @@ -1,7 +1,7 @@ // 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; `Photo.exif()` -// returns the common fields picked from them here. +// `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, { type ExpandedTags } from "exifreader"; @@ -9,7 +9,11 @@ import ExifReader, { type ExpandedTags } from "exifreader"; // 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`. -// Each tag's `computed` holds its value as a plain string or number. +// `exif` holds every tag except the thumbnail's; a tag exifreader has no name +// for is keyed `undefined-`. 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( @@ -18,6 +22,7 @@ export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { expanded: true, computed: true, includeOffsets: true, + includeUnknown: true, includeTags: { exif: true, gps: true }, }, ); @@ -77,6 +82,7 @@ export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { const tags = readExifTags(bytes); const exif = tags?.exif; const gps = tags?.gps; + const altitude = asNumber(exif?.GPSAltitude?.computed); const fields: PhotoExif = { make: asString(exif?.Make?.computed), model: asString(exif?.Model?.computed), @@ -91,7 +97,11 @@ export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { orientation: asNumber(exif?.Orientation?.computed), gpsLatitude: asNumber(gps?.Latitude), gpsLongitude: asNumber(gps?.Longitude), - gpsAltitude: asNumber(gps?.Altitude), + // A GPSAltitudeRef of 1 means the altitude is below sea level. + gpsAltitude: + altitude !== undefined && exif?.GPSAltitudeRef?.value === 1 + ? -altitude + : altitude, }; // Leave out what the file lacks, so a missing field is absent rather than // present and undefined. diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 2deda57..2bdd533 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -46,6 +46,23 @@ const TIFF_UNSET_DATE = [ ...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56 ]; +// A big-endian TIFF block holding a GPSAltitude of 12.5 m and no +// GPSAltitudeRef. +const TIFF_ALTITUDE_WITHOUT_REF = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: one entry, then no next IFD. + ...[0x00, 0x01], + // The GPS IFD's offset (0x8825), LONG, 26. + ...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a], + ...[0x00, 0x00, 0x00, 0x00], + // The GPS IFD, at 26: one entry, then no next IFD. + ...[0x00, 0x01], + // GPSAltitude (0x0006), one RATIONAL at 44. + ...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c], + ...[0x00, 0x00, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44 +]; + // An APP1 segment whose length field matches its data. const app1 = (data: number[]): number[] => { const len = data.length + 2; @@ -70,6 +87,13 @@ describe("readPhotoExif", () => { }); }); + it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => { + const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF]; + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + gpsAltitude: 12.5, + }); + }); + it.each([ [ "a file that is not an image", -- 2.54.0 From 0813163e4de16b7b9f0946597de3c1041eb65c7b Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 19:59:36 +0000 Subject: [PATCH 4/5] Drop exifreader's faulty-value text; test unnamed EXIF tags A text tag whose value lies outside the file, which exifreader reads as "", no longer gives a make, model, lens model or offset field. Tests cover such a JPEG and the backup-metadata dump keying a tag exifreader has no name for as undefined-. Model: opus-5-5 --- src/exif.ts | 6 +++++- test/cli/metadata-exif.test.ts | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/exif.ts b/src/exif.ts index 224631c..b0f13f5 100644 --- a/src/exif.ts +++ b/src/exif.ts @@ -58,8 +58,12 @@ export interface PhotoExif { gpsAltitude?: number; } +// exifreader gives "" 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 !== "" + ? v + : undefined; const asNumber = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined; diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 2bdd533..273181c 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -63,6 +63,30 @@ const TIFF_ALTITUDE_WITHOUT_REF = [ ...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44 ]; +// A big-endian TIFF block holding Orientation 6 and a Make whose value lies +// past the end of the file. +const TIFF_MAKE_PAST_END = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: two entries, then no next IFD. + ...[0x00, 0x02], + // Make (0x010f), 6 ASCII bytes at 4096. + ...[0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x10, 0x00], + // Orientation (0x0112), SHORT, 6. + ...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x00], +]; + +// A big-endian TIFF block with one IFD entry that exifreader has no name for: +// tag 0xc000 (49152), SHORT, 7. +const TIFF_UNNAMED_TAG = [ + ...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8 + // The first IFD, at 8: one entry, then no next IFD. + ...[0x00, 0x01], + // Tag 0xc000, SHORT, 7. + ...[0xc0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00], + ...[0x00, 0x00, 0x00, 0x00], +]; + // An APP1 segment whose length field matches its data. const app1 = (data: number[]): number[] => { const len = data.length + 2; @@ -94,6 +118,13 @@ describe("readPhotoExif", () => { }); }); + it("gives no make for a Make whose value lies past the end of the file", () => { + const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END]; + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + orientation: 6, + }); + }); + it.each([ [ "a file that is not an image", @@ -170,6 +201,14 @@ describe("extractImageMetadata", () => { }); }); + it("keys a tag exifreader has no name for by its number", () => { + const meta = extractImageMetadata( + bytes(SOI, app1([...EXIF_HEADER, ...TIFF_UNNAMED_TAG]), SOS), + ); + expect(meta?.exifError).toBeUndefined(); + expect(meta?.exif).toMatchObject({ "undefined-49152": { value: 7 } }); + }); + it("returns nothing for a file that is not an image", () => { const text = new TextEncoder().encode("just some text, not an image"); expect(extractImageMetadata(text)).toBeUndefined(); -- 2.54.0 From 78c64061a3af1968904d08203ddfa58511ac3cb5 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 20:23:38 +0000 Subject: [PATCH 5/5] README: give exif()'s formats as examples, TIFF among them The read surface listed JPEG, HEIC/HEIF, AVIF, PNG and WebP as every format exif() reads, but exifreader reads more, so "any other original gives {}" was false. The list is now examples. Model: opus-5-5 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 66d60ea..80c573d 100644 --- a/README.md +++ b/README.md @@ -725,9 +725,9 @@ Four async methods may download: each absent when the file lacks it. GPS values are signed decimal degrees and metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from - UTC. EXIF is read from any image format exifreader reads: JPEG, HEIC/HEIF, - AVIF, PNG and WebP, a live photo's image included. Any other original gives - `{}`, and a video gives `{}` without being downloaded. + UTC. EXIF is read from any image format exifreader reads (such as JPEG, + HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other + original gives `{}`, and a video gives `{}` without being downloaded. They serve from the on-disk content cache when the bytes are present and otherwise fetch through the pools; `original()`, `content()` and `exif()` also -- 2.54.0