Photo: one async method per EXIF field (closes #148)
check / check (push) Successful in 1m46s

Thirteen methods on Photo, make() through gpsAltitude(), each named after
its PhotoExif field and typed by it. Each calls exif() with the same opts
and returns that one field, or undefined when the file lacks it. A test
checks, on the JPEG fixture and on test/exif.heic, that every field
exif() returns has a method giving the same value. README and TODO.md
list them.

Model: opus-5-5
This commit is contained in:
2026-10-01 20:37:02 +00:00
parent 67d554fb46
commit b669df220a
4 changed files with 106 additions and 10 deletions
+7 -1
View File
@@ -711,7 +711,7 @@ synchronous getters look at the disk and never touch the network:
or no content source. or no content source.
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. - `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 - `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
full-resolution file. For a live photo, `path` and `bytes` are its image's and 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, 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 HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other
original gives `{}`, and a video gives `{}` without being downloaded. 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 They serve from the on-disk content cache when the bytes are present and
otherwise fetch through the pools; `original()`, `content()` and `exif()` also otherwise fetch through the pools; `original()`, `content()` and `exif()` also
+8
View File
@@ -25,6 +25,14 @@ declares one.
# Completed Steps # 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 - 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 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 the other image formats `exifreader` reads (issue 145). `exifreader` replaces
+64 -4
View File
@@ -12,10 +12,11 @@
// plain records are the serializable surface, and `record()` returns one. // plain records are the serializable surface, and `record()` returns one.
// //
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, // A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
// `content()` and `exif()` go through the on-disk content cache (issue #46), // `content()`, `exif()` and the methods that each return one field of `exif()`
// and are the one place in this module that may touch the network. A library // go through the on-disk content cache (issue #46), and are the one place in
// opened without a content source leaves that cache absent, and those methods // this module that may touch the network. A library opened without a content
// then throw. `savePath` and `isLocal` look only at the disk. // source leaves that cache absent, and those methods then throw. `savePath` and
// `isLocal` look only at the disk.
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
@@ -140,6 +141,65 @@ export class Photo {
return readPhotoExif(await this.content(opts)); 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<PhotoExif["make"]> {
return (await this.exif(opts)).make;
}
async model(opts?: ContentOptions): Promise<PhotoExif["model"]> {
return (await this.exif(opts)).model;
}
async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> {
return (await this.exif(opts)).lensModel;
}
async dateTimeOriginal(
opts?: ContentOptions,
): Promise<PhotoExif["dateTimeOriginal"]> {
return (await this.exif(opts)).dateTimeOriginal;
}
async offsetTimeOriginal(
opts?: ContentOptions,
): Promise<PhotoExif["offsetTimeOriginal"]> {
return (await this.exif(opts)).offsetTimeOriginal;
}
async exposureTime(
opts?: ContentOptions,
): Promise<PhotoExif["exposureTime"]> {
return (await this.exif(opts)).exposureTime;
}
async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> {
return (await this.exif(opts)).fNumber;
}
async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> {
return (await this.exif(opts)).iso;
}
async focalLength(
opts?: ContentOptions,
): Promise<PhotoExif["focalLength"]> {
return (await this.exif(opts)).focalLength;
}
async orientation(
opts?: ContentOptions,
): Promise<PhotoExif["orientation"]> {
return (await this.exif(opts)).orientation;
}
async gpsLatitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsLatitude"]> {
return (await this.exif(opts)).gpsLatitude;
}
async gpsLongitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsLongitude"]> {
return (await this.exif(opts)).gpsLongitude;
}
async gpsAltitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsAltitude"]> {
return (await this.exif(opts)).gpsAltitude;
}
private cacheOrThrow(): PhotoContent { private cacheOrThrow(): PhotoContent {
if (!this.cache) { if (!this.cache) {
throw new Error( throw new Error(
+27 -5
View File
@@ -6,8 +6,8 @@
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and * `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 * a cached path shows up on the projected record. A library opened without a
* content source leaves those methods throwing rather than silently doing * content source leaves those methods throwing rather than silently doing
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()` and * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()`,
* `exif()`. * `exif()` and the methods that each return one field of `exif()`.
*/ */
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
@@ -420,8 +420,8 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
// What exif() returns for HEIC_WITH_EXIF, which holds the same values as // What exif() returns for HEIC_WITH_EXIF, and for JPEG_WITH_EXIF, which
// JPEG_WITH_EXIF. // holds the same values.
const heicFields: PhotoExif = { const heicFields: PhotoExif = {
make: "Canon", make: "Canon",
model: "EOS R5", model: "EOS R5",
@@ -462,9 +462,31 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); 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 () => { it("returns no EXIF fields for an original that is not an image", async () => {
const lib = await open(); 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(); await lib.close();
}); });