Compare commits
2
Commits
1ede66e5c0
...
4986ebd889
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4986ebd889 | ||
|
|
b669df220a |
@@ -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
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ 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. `Photo` implements a type with one
|
||||||
|
method per `PhotoExif` field, so the build's type check fails when a field has
|
||||||
|
no method. A test checks, on the JPEG and the HEIC, that each method gives the
|
||||||
|
same value as `exif()`.
|
||||||
|
|
||||||
- 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
|
||||||
|
|||||||
+72
-5
@@ -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";
|
||||||
|
|
||||||
@@ -34,9 +35,16 @@ const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
|
|||||||
const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
||||||
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
|
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
|
||||||
|
|
||||||
|
// A method for each `PhotoExif` field, named after it, taking the options
|
||||||
|
// `exif()` takes and giving that field. `Photo` implements it, so the build's
|
||||||
|
// type check fails when `PhotoExif` has a field `Photo` has no method for.
|
||||||
|
type PhotoExifMethods = {
|
||||||
|
[K in keyof PhotoExif]-?: (opts?: ContentOptions) => Promise<PhotoExif[K]>;
|
||||||
|
};
|
||||||
|
|
||||||
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
||||||
// underlying plain record for callers that need the IPC-safe value.
|
// underlying plain record for callers that need the IPC-safe value.
|
||||||
export class Photo {
|
export class Photo implements PhotoExifMethods {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rec: PhotoRecord,
|
private readonly rec: PhotoRecord,
|
||||||
private readonly cache?: PhotoContent,
|
private readonly cache?: PhotoContent,
|
||||||
@@ -140,6 +148,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(
|
||||||
|
|||||||
@@ -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,35 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
|||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The build's type check, not this test, makes sure `Photo` has a method
|
||||||
|
// for every `PhotoExif` field, whatever the fixtures hold: `Photo`
|
||||||
|
// implements a type with one method per field. This test checks that each
|
||||||
|
// method gives the same value as exif().
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user