diff --git a/README.md b/README.md index 80c573d..9358ac9 100644 --- a/README.md +++ b/README.md @@ -711,7 +711,7 @@ synchronous getters look at the disk and never touch the network: or no content source. - `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. -Four async methods may download: +These async methods may download: - `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the full-resolution file. For a live photo, `path` and `bytes` are its image's and @@ -728,6 +728,12 @@ Four async methods may download: 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. +- `await photo.make(opts?)`, and likewise `model()`, `lensModel()`, + `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, + `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` + and `gpsAltitude()` → one field of `exif()` each, typed as in `PhotoExif`, or + `undefined` when the file lacks it. Each calls `exif()` with its `opts`, so + each call reads the original again. 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 b342066..a799e22 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ declares one. # Completed Steps +- 2026-10-01: A `Photo` has one async method for each field of `exif()`, named + and typed as in `PhotoExif`: `make()`, `model()`, `lensModel()`, + `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, + `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` + and `gpsAltitude()` (issue 148). Each calls `exif()` and returns its one + field, or undefined when the file lacks it. A test checks, on the JPEG and the + HEIC, that every field `exif()` returns has a method giving the same value. + - 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 diff --git a/src/library/read.ts b/src/library/read.ts index 68a8dd2..c2d8203 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -12,10 +12,11 @@ // plain records are the serializable surface, and `record()` returns one. // // A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, -// `content()` and `exif()` go through the on-disk content cache (issue #46), -// and are the one place in this module that may touch the network. A library -// opened without a content source leaves that cache absent, and those methods -// then throw. `savePath` and `isLocal` look only at the disk. +// `content()`, `exif()` and the methods that each return one field of `exif()` +// go through the on-disk content cache (issue #46), and are the one place in +// this module that may touch the network. A library opened without a content +// source leaves that cache absent, and those methods then throw. `savePath` and +// `isLocal` look only at the disk. import { readFile } from "node:fs/promises"; @@ -140,6 +141,65 @@ export class Photo { return readPhotoExif(await this.content(opts)); } + // One field of `exif()` each, named and typed as in `PhotoExif`, and + // undefined when the file lacks it. Each call runs `exif()`, which reads + // the original again. + async make(opts?: ContentOptions): Promise { + return (await this.exif(opts)).make; + } + async model(opts?: ContentOptions): Promise { + return (await this.exif(opts)).model; + } + async lensModel(opts?: ContentOptions): Promise { + return (await this.exif(opts)).lensModel; + } + async dateTimeOriginal( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).dateTimeOriginal; + } + async offsetTimeOriginal( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).offsetTimeOriginal; + } + async exposureTime( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).exposureTime; + } + async fNumber(opts?: ContentOptions): Promise { + return (await this.exif(opts)).fNumber; + } + async iso(opts?: ContentOptions): Promise { + return (await this.exif(opts)).iso; + } + async focalLength( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).focalLength; + } + async orientation( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).orientation; + } + async gpsLatitude( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).gpsLatitude; + } + async gpsLongitude( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).gpsLongitude; + } + async gpsAltitude( + opts?: ContentOptions, + ): Promise { + return (await this.exif(opts)).gpsAltitude; + } + private cacheOrThrow(): PhotoContent { if (!this.cache) { throw new Error( diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index 7e5ad27..7864483 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -6,8 +6,8 @@ * `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and * a cached path shows up on the projected record. A library opened without a * content source leaves those methods throwing rather than silently doing - * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()` and - * `exif()`. + * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()`, + * `exif()` and the methods that each return one field of `exif()`. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; @@ -420,8 +420,8 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); - // What exif() returns for HEIC_WITH_EXIF, which holds the same values as - // JPEG_WITH_EXIF. + // What exif() returns for HEIC_WITH_EXIF, and for JPEG_WITH_EXIF, which + // holds the same values. const heicFields: PhotoExif = { make: "Canon", model: "EOS R5", @@ -462,9 +462,31 @@ describe("Photo save path, local copy, content and EXIF", () => { await lib.close(); }); + it.each([ + ["JPEG", JPEG_WITH_EXIF], + ["HEIC", HEIC_WITH_EXIF], + ])( + "has a method for each field exif() returns, giving the same value, for a %s", + async (_, bytes) => { + const lib = await open({ contentSource: stubSource(bytes) }); + const photo = lib.photos.byID({ fileID: 1 })!; + const exif = await photo.exif(); + // The file holds every field, so every method is checked. + expect(exif).toStrictEqual(heicFields); + for (const [field, value] of Object.entries(exif)) { + expect(await photo[field as keyof PhotoExif]()).toStrictEqual( + value, + ); + } + 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({}); + const photo = lib.photos.byID({ fileID: 1 })!; + expect(await photo.exif()).toStrictEqual({}); + expect(await photo.dateTimeOriginal()).toBeUndefined(); await lib.close(); });