Photo: one async method per EXIF field (closes #148) #149

Merged
clawbot merged 2 commits from issue-148-exif-field-methods into next 2026-10-02 00:09:24 +02:00
4 changed files with 105 additions and 10 deletions
Showing only changes of commit f2f3395f94 - Show all commits
+7 -1
View File
@@ -726,7 +726,7 @@ synchronous getters look at the disk and never touch the network:
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. A - `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. A
copy only in the cache does not count. copy only in the cache does not count.
Five 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
@@ -748,6 +748,12 @@ Five 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: Each original's save path is - 2026-10-01: Each original's save path is
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's
download directory, which defaults to `photos` in the working directory (issue download directory, which defaults to `photos` in the working directory (issue
+64 -5
View File
@@ -12,11 +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()`,
// `download()`, `content()` and `exif()` go through the on-disk content cache // `download()`, `content()`, `exif()` and the methods that each return one
// (issue #46), and are the one place in this module that may touch the // field of `exif()` go through the on-disk content cache (issue #46), and are
// network. A library opened without a content source leaves that cache absent, // the one place in this module that may touch the network. A library opened
// and those methods then throw. `savePath` and `isLocal` look only at the disk // without a content source leaves that cache absent, and those methods then
// and need no cache. // throw. `savePath` and `isLocal` look only at the disk and need no cache.
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
@@ -160,6 +160,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(
+26 -4
View File
@@ -7,7 +7,7 @@
* 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`, `download()`, * nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`,
* `content()` and `exif()`. * `content()`, `exif()` and the methods that each return one field of `exif()`.
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
@@ -644,8 +644,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",
@@ -686,9 +686,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();
}); });