exif() returns every EXIF tag in the file (closes #156)
check / check (push) Waiting to run

`photo.exif()` returns `ExifTags`: every EXIF tag exifreader reads from the file, keyed by tag name, each with exifreader's `id`, `value`, `description` and `computed`. The embedded thumbnail's tags are under `Thumbnail`, without the thumbnail image. A file with no EXIF, or a video, gives `{}`.

The thirteen typed methods stay. Each picks its field from the tags `exif()` returns through `readPhotoExif`, which now takes the tags instead of the bytes. GPS latitude and longitude are worked out from their tags and reference tags, since exifreader's computed position is not among the tags.

Also closes #148.

Model: opus-5-5
This commit is contained in:
2026-10-02 02:25:28 +00:00
committed by clawbot
parent 0c995a8c4f
commit 6dd22bcb73
8 changed files with 305 additions and 124 deletions
+19 -14
View File
@@ -101,7 +101,8 @@ requires one, and writes:
photo its image, its video and the `.livephoto.json` file naming them photo its image, its video and the `.livephoto.json` file naming them
- beside each original, a JSON file named after it with `.json` added, for - beside each original, a JSON file named after it with `.json` added, for
example `2026-03-01.12345.jpg.json`: the photo's record (`photo.record()`) example `2026-03-01.12345.jpg.json`: the photo's record (`photo.record()`)
without its cache paths, and its EXIF fields (`photo.exif()`) under `exif` without its cache paths, and every EXIF tag of the photo (`photo.exif()`)
under `exif`
- `albums/<collectionID>.json` for each album: its `collectionID`, its `name`, - `albums/<collectionID>.json` for each album: its `collectionID`, its `name`,
and under `savePaths` the save paths of its photos relative to `dir`, newest and under `savePaths` the save paths of its photos relative to `dir`, newest
first first
@@ -773,21 +774,25 @@ These async methods may download:
- `await photo.thumbnail(opts?)` → `{ path, bytes }`. - `await photo.thumbnail(opts?)` → `{ path, bytes }`.
- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read - `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read
through `original()`; for a live photo, its image's. through `original()`; for a live photo, its image's.
- `await photo.exif(opts?)` → `PhotoExif` — `make`, `model`, `lensModel`, - `await photo.exif(opts?)` → `ExifTags` — every EXIF tag in the file, keyed by
`dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`, tag name, each as exifreader decodes it, with its `id`, `value`, `description`
`focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`, and `computed` value: for example `Make` is
each absent when the file lacks it. GPS values are signed decimal degrees and `{ id: 271, value: ["Canon"], description: "Canon", computed: "Canon" }`. A
metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s tag exifreader has no name for is keyed `undefined-<tag number>`. The embedded
UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from thumbnail's tags are under `Thumbnail`, so they cannot hide the main image's
UTC. EXIF is read from any image format exifreader reads (such as JPEG, tags of the same name; the thumbnail image itself is left out. EXIF is read
HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other from any image format exifreader reads (such as JPEG, HEIC/HEIF, AVIF, PNG,
original gives `{}`, and a video gives `{}` without being downloaded. 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()`, - `await photo.make(opts?)`, and likewise `model()`, `lensModel()`,
`dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`, `dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`,
`iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()` `iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()`
and `gpsAltitude()` → one field of `exif()` each, typed as in `PhotoExif`, or and `gpsAltitude()` → one common field each, picked from the tags `exif()`
`undefined` when the file lacks it. Each calls `exif()` with its `opts`, so returns and typed as in `PhotoExif`, or `undefined` when the file lacks it.
each call reads the original again. 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. 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
@@ -904,7 +909,7 @@ from a very old client, is stored unchecked.
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`, - `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
`LibraryChange` `LibraryChange`
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult` - `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
- `src/exif.ts`: `PhotoExif` - `src/exif.ts`: `ExifTags`, `PhotoExif`
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool` - `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError` - `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot` - `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
+7
View File
@@ -25,6 +25,13 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`,
keyed by tag name, each as exifreader decodes it, not only the thirteen common
fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`,
without the thumbnail image. The thirteen typed methods stay, each picking its
field from the tags `exif()` returns, typed as in `PhotoExif`. The example
script's JSON files now carry every tag.
- 2026-10-01: The content cache no longer looks for a live photo that an earlier - 2026-10-01: The content cache no longer looks for a live photo that an earlier
version cached as one ZIP (issue 151). When the cache opens, a live photo's version cached as one ZIP (issue 151). When the cache opens, a live photo's
file that no JSON file names is now always left alone. file that no JSON file names is now always left alone.
+80 -31
View File
@@ -1,19 +1,19 @@
// EXIF in an original's bytes, read with exifreader, which reads it from JPEG, // 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. // HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports.
// `backup-metadata --exif` records every EXIF tag it finds except the // `backup-metadata --exif` records every EXIF tag it finds except the
// thumbnail's; `Photo.exif()` returns the common fields picked from them here. // thumbnail's. `Photo.exif()` returns every tag, the thumbnail's included, and
// `Photo`'s typed methods return the common fields picked from them here.
import ExifReader, { type ExpandedTags } from "exifreader"; import ExifReader, { type ExpandedTags } from "exifreader";
// The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from // The EXIF tags in `bytes` (`exif`), the embedded thumbnail's tags
// them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`). // (`Thumbnail`), and where the EXIF block lies in `bytes` (`metadataRange`).
// Undefined when exifreader cannot read the file at all, such as a video. An // 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`. // EXIF block it finds but reads no tag from comes back as an empty `exif`. A
// `exif` holds every tag except the thumbnail's; a tag exifreader has no name // tag exifreader has no name for is keyed `undefined-<tag number>`. Each tag's
// for is keyed `undefined-<tag number>`. Each tag's `computed` holds its value // `computed` holds its value as a string or number, or as an array of them for
// as a string or number, or as an array of them for a tag with several values, // a tag with several values, such as `GPSLatitude`'s `[40, 26, 46]`. A
// such as `GPSLatitude`'s `[40, 26, 46]`. A fraction with a zero denominator // fraction with a zero denominator computes to null.
// computes to null.
export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
try { try {
return ExifReader.loadView( return ExifReader.loadView(
@@ -23,7 +23,7 @@ export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
computed: true, computed: true,
includeOffsets: true, includeOffsets: true,
includeUnknown: true, includeUnknown: true,
includeTags: { exif: true, gps: true }, includeTags: { exif: true, thumbnail: true },
}, },
); );
} catch { } catch {
@@ -31,7 +31,35 @@ export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
} }
}; };
// The common EXIF fields of an original. Each is absent when the file lacks it. // Every EXIF tag of an original, keyed by name, each as exifreader decodes it
// (see `readExifTags`). The embedded thumbnail's own tags are under
// `Thumbnail`, so its `Orientation` or `ImageWidth` cannot hide the main
// image's.
export type ExifTags = Omit<NonNullable<ExpandedTags["exif"]>, "Thumbnail"> & {
Thumbnail?: Omit<
NonNullable<ExpandedTags["Thumbnail"]>,
"type" | "image" | "base64"
>;
};
// Every EXIF tag in `bytes`: `{}` when the file has no EXIF, exifreader cannot
// read its EXIF, or it is not an image exifreader reads.
export const readAllExifTags = (bytes: Uint8Array): ExifTags => {
const tags = readExifTags(bytes);
if (!tags?.Thumbnail) return tags?.exif ?? {};
// exifreader puts the thumbnail's JPEG image beside its tags, as `type`,
// `image` and `base64`. The image is not a tag, so it is left out.
const {
type: _type,
image: _image,
base64: _base64,
...thumbnail
} = tags.Thumbnail;
return { ...tags.exif, Thumbnail: thumbnail };
};
// The common EXIF fields of an original, one for each of `Photo`'s typed
// methods. Each is absent when the file lacks it.
export interface PhotoExif { export interface PhotoExif {
make?: string; make?: string;
model?: string; model?: string;
@@ -80,30 +108,51 @@ const asDate = (v: unknown): Date | undefined => {
return Number.isNaN(date.getTime()) ? undefined : date; return Number.isNaN(date.getTime()) ? undefined : date;
}; };
// The common fields of an original's EXIF: `{}` when the file has no EXIF, // GPSLatitude and GPSLongitude hold degrees, minutes and seconds, computed as
// exifreader cannot read its EXIF, or it is not an image exifreader reads. // three numbers. This is them in decimal degrees, negative when `ref`, the
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { // GPSLatitudeRef or GPSLongitudeRef tag, is `negativeRef` ("S" or "W").
const tags = readExifTags(bytes); // Without that tag the hemisphere is unknown, so it is undefined.
const exif = tags?.exif; const asDegrees = (
const gps = tags?.gps; dms: unknown,
const altitude = asNumber(exif?.GPSAltitude?.computed); ref: unknown,
negativeRef: string,
): number | undefined => {
if (!Array.isArray(dms) || ref === undefined) return undefined;
const [d, m, s] = dms.map(asNumber);
if (d === undefined || m === undefined || s === undefined) return undefined;
const degrees = d + m / 60 + s / 3600;
return ref === negativeRef ? -degrees : degrees;
};
// The common fields picked from an original's EXIF tags, `readAllExifTags`'s
// result: `{}` when there are none.
export const readPhotoExif = (tags: ExifTags): PhotoExif => {
const altitude = asNumber(tags.GPSAltitude?.computed);
const fields: PhotoExif = { const fields: PhotoExif = {
make: asString(exif?.Make?.computed), make: asString(tags.Make?.computed),
model: asString(exif?.Model?.computed), model: asString(tags.Model?.computed),
lensModel: asString(exif?.LensModel?.computed), lensModel: asString(tags.LensModel?.computed),
dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed), dateTimeOriginal: asDate(tags.DateTimeOriginal?.computed),
offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed), offsetTimeOriginal: asString(tags.OffsetTimeOriginal?.computed),
exposureTime: asNumber(exif?.ExposureTime?.computed), exposureTime: asNumber(tags.ExposureTime?.computed),
fNumber: asNumber(exif?.FNumber?.computed), fNumber: asNumber(tags.FNumber?.computed),
// Only when the tag holds a single number, as most cameras write it. // Only when the tag holds a single number, as most cameras write it.
iso: asNumber(exif?.ISOSpeedRatings?.computed), iso: asNumber(tags.ISOSpeedRatings?.computed),
focalLength: asNumber(exif?.FocalLength?.computed), focalLength: asNumber(tags.FocalLength?.computed),
orientation: asNumber(exif?.Orientation?.computed), orientation: asNumber(tags.Orientation?.computed),
gpsLatitude: asNumber(gps?.Latitude), gpsLatitude: asDegrees(
gpsLongitude: asNumber(gps?.Longitude), tags.GPSLatitude?.computed,
tags.GPSLatitudeRef?.computed,
"S",
),
gpsLongitude: asDegrees(
tags.GPSLongitude?.computed,
tags.GPSLongitudeRef?.computed,
"W",
),
// A GPSAltitudeRef of 1 means the altitude is below sea level. // A GPSAltitudeRef of 1 means the altitude is below sea level.
gpsAltitude: gpsAltitude:
altitude !== undefined && exif?.GPSAltitudeRef?.value === 1 altitude !== undefined && tags.GPSAltitudeRef?.value === 1
? -altitude ? -altitude
: altitude, : altitude,
}; };
+1 -1
View File
@@ -85,7 +85,7 @@ export type {
LibrarySnapshot, LibrarySnapshot,
LibraryChange, LibraryChange,
} from "./library/records.js"; } from "./library/records.js";
export type { PhotoExif } from "./exif.js"; export type { ExifTags, PhotoExif } from "./exif.js";
export { decryptCollection, decryptFile } from "./model/index.js"; export { decryptCollection, decryptFile } from "./model/index.js";
export { downloadFile, downloadThumbnail } from "./download/index.js"; export { downloadFile, downloadThumbnail } from "./download/index.js";
export type { export type {
+31 -25
View File
@@ -13,14 +13,19 @@
// //
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, // A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
// `download()`, `content()`, `exif()` and the methods that each return one // `download()`, `content()`, `exif()` and the methods that each return one
// field of `exif()` go through the on-disk content cache (issue #46), and are // EXIF field 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 // 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 // without a content source leaves that cache absent, and those methods then
// throw. `savePath` and `isLocal` look only at the disk 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";
import { readPhotoExif, type PhotoExif } from "../exif.js"; import {
readAllExifTags,
readPhotoExif,
type ExifTags,
type PhotoExif,
} from "../exif.js";
import type { CollectionType, EnteFile, FileType } from "../model/types.js"; import type { CollectionType, EnteFile, FileType } from "../model/types.js";
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js"; import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js"; import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
@@ -156,74 +161,75 @@ export class Photo implements PhotoExifMethods {
return readFile(path); return readFile(path);
} }
// The common EXIF fields of the original, read from `content()`, so this // Every EXIF tag of the original, keyed by name (see `ExifTags`), read
// may download it. EXIF is read from any image format exifreader reads, // from `content()`, so this may download it. EXIF is read from any image
// JPEG and HEIC/HEIF among them; any other file gives `{}`, and a video // format exifreader reads, JPEG and HEIC/HEIF among them; any other file
// gives it without fetching anything. Like the other content methods, it // gives `{}`, and a video gives it without fetching anything. Like the
// throws when there is no content cache, video or not. // other content methods, it throws when there is no content cache, video
async exif(opts?: ContentOptions): Promise<PhotoExif> { // or not.
async exif(opts?: ContentOptions): Promise<ExifTags> {
this.cacheOrThrow(); this.cacheOrThrow();
if (this.rec.fileType === "video") return {}; if (this.rec.fileType === "video") return {};
return readPhotoExif(await this.content(opts)); return readAllExifTags(await this.content(opts));
} }
// One field of `exif()` each, named and typed as in `PhotoExif`, and // One field each, named and typed as in `PhotoExif`, picked from the tags
// undefined when the file lacks it. Each call runs `exif()`, which reads // `exif()` returns, and undefined when the file lacks it. Each call runs
// the original again. // `exif()`, which reads the original again.
async make(opts?: ContentOptions): Promise<PhotoExif["make"]> { async make(opts?: ContentOptions): Promise<PhotoExif["make"]> {
return (await this.exif(opts)).make; return readPhotoExif(await this.exif(opts)).make;
} }
async model(opts?: ContentOptions): Promise<PhotoExif["model"]> { async model(opts?: ContentOptions): Promise<PhotoExif["model"]> {
return (await this.exif(opts)).model; return readPhotoExif(await this.exif(opts)).model;
} }
async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> { async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> {
return (await this.exif(opts)).lensModel; return readPhotoExif(await this.exif(opts)).lensModel;
} }
async dateTimeOriginal( async dateTimeOriginal(
opts?: ContentOptions, opts?: ContentOptions,
): Promise<PhotoExif["dateTimeOriginal"]> { ): Promise<PhotoExif["dateTimeOriginal"]> {
return (await this.exif(opts)).dateTimeOriginal; return readPhotoExif(await this.exif(opts)).dateTimeOriginal;
} }
async offsetTimeOriginal( async offsetTimeOriginal(
opts?: ContentOptions, opts?: ContentOptions,
): Promise<PhotoExif["offsetTimeOriginal"]> { ): Promise<PhotoExif["offsetTimeOriginal"]> {
return (await this.exif(opts)).offsetTimeOriginal; return readPhotoExif(await this.exif(opts)).offsetTimeOriginal;
} }
async exposureTime( async exposureTime(
opts?: ContentOptions, opts?: ContentOptions,
): Promise<PhotoExif["exposureTime"]> { ): Promise<PhotoExif["exposureTime"]> {
return (await this.exif(opts)).exposureTime; return readPhotoExif(await this.exif(opts)).exposureTime;
} }
async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> { async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> {
return (await this.exif(opts)).fNumber; return readPhotoExif(await this.exif(opts)).fNumber;
} }
async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> { async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> {
return (await this.exif(opts)).iso; return readPhotoExif(await this.exif(opts)).iso;
} }
async focalLength( async focalLength(
opts?: ContentOptions, opts?: ContentOptions,
): Promise<PhotoExif["focalLength"]> { ): Promise<PhotoExif["focalLength"]> {
return (await this.exif(opts)).focalLength; return readPhotoExif(await this.exif(opts)).focalLength;
} }
async orientation( async orientation(
opts?: ContentOptions, opts?: ContentOptions,
): Promise<PhotoExif["orientation"]> { ): Promise<PhotoExif["orientation"]> {
return (await this.exif(opts)).orientation; return readPhotoExif(await this.exif(opts)).orientation;
} }
async gpsLatitude( async gpsLatitude(
opts?: ContentOptions, opts?: ContentOptions,
): Promise<PhotoExif["gpsLatitude"]> { ): Promise<PhotoExif["gpsLatitude"]> {
return (await this.exif(opts)).gpsLatitude; return readPhotoExif(await this.exif(opts)).gpsLatitude;
} }
async gpsLongitude( async gpsLongitude(
opts?: ContentOptions, opts?: ContentOptions,
): Promise<PhotoExif["gpsLongitude"]> { ): Promise<PhotoExif["gpsLongitude"]> {
return (await this.exif(opts)).gpsLongitude; return readPhotoExif(await this.exif(opts)).gpsLongitude;
} }
async gpsAltitude( async gpsAltitude(
opts?: ContentOptions, opts?: ContentOptions,
): Promise<PhotoExif["gpsAltitude"]> { ): Promise<PhotoExif["gpsAltitude"]> {
return (await this.exif(opts)).gpsAltitude; return readPhotoExif(await this.exif(opts)).gpsAltitude;
} }
private cacheOrThrow(): PhotoContent { private cacheOrThrow(): PhotoContent {
+75 -8
View File
@@ -3,14 +3,18 @@
* `quak backup-metadata --exif` records. * `quak backup-metadata --exif` records.
* *
* The originals come from users' libraries, so a truncated or corrupt file * The originals come from users' libraries, so a truncated or corrupt file
* must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`, * must neither hang the read nor throw out of it: `readAllExifTags` gives `{}`,
* and `backup-metadata` tells an EXIF block it cannot read apart from a file * 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 * 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. * is a short hand-built byte array; the HEIC is a real file.
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { readPhotoExif } from "../../src/exif.js"; import {
readAllExifTags,
readExifTags,
readPhotoExif,
} from "../../src/exif.js";
import { extractImageMetadata } from "../../src/metadata-backup.js"; import { extractImageMetadata } from "../../src/metadata-backup.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js";
@@ -87,6 +91,27 @@ const TIFF_UNNAMED_TAG = [
...[0x00, 0x00, 0x00, 0x00], ...[0x00, 0x00, 0x00, 0x00],
]; ];
// A big-endian TIFF block holding Orientation 6, and a thumbnail IFD holding
// its own Orientation 1 and a 4-byte JPEG thumbnail.
const TIFF_WITH_THUMBNAIL = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then the thumbnail IFD at 26.
...[0x00, 0x01],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x1a],
// The thumbnail IFD, at 26: three entries, then no next IFD.
...[0x00, 0x03],
// Orientation (0x0112), SHORT, 1.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00],
// JPEGInterchangeFormat (0x0201), LONG: the thumbnail is at 68.
...[0x02, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x44],
// JPEGInterchangeFormatLength (0x0202), LONG: 4 bytes.
...[0x02, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04],
...[0x00, 0x00, 0x00, 0x00],
...[0xff, 0xd8, 0xff, 0xd9], // the thumbnail, at 68: an empty JPEG
];
// An APP1 segment whose length field matches its data. // An APP1 segment whose length field matches its data.
const app1 = (data: number[]): number[] => { const app1 = (data: number[]): number[] => {
const len = data.length + 2; const len = data.length + 2;
@@ -96,31 +121,72 @@ const app1 = (data: number[]): number[] => {
const bytes = (...parts: number[][]): Uint8Array => const bytes = (...parts: number[][]): Uint8Array =>
new Uint8Array(parts.flat()); new Uint8Array(parts.flat());
describe("readAllExifTags", () => {
it("keys a tag exifreader has no name for by its number", () => {
const data = [...EXIF_HEADER, ...TIFF_UNNAMED_TAG];
expect(readAllExifTags(bytes(SOI, app1(data), SOS))).toStrictEqual({
"undefined-49152": {
id: 49152,
value: 7,
description: 7,
computed: 7,
},
});
});
it("puts the thumbnail's tags under Thumbnail, without its image", () => {
const input = bytes(
SOI,
app1([...EXIF_HEADER, ...TIFF_WITH_THUMBNAIL]),
SOS,
);
// exifreader finds the thumbnail's image.
expect(readExifTags(input)?.Thumbnail?.type).toBe("image/jpeg");
const tags = readAllExifTags(input);
expect(tags.Orientation?.value).toBe(6);
expect(Object.keys(tags.Thumbnail ?? {}).sort()).toEqual([
"JPEGInterchangeFormat",
"JPEGInterchangeFormatLength",
"Orientation",
]);
expect(tags.Thumbnail?.Orientation?.value).toBe(1);
expect(readPhotoExif(tags)).toStrictEqual({ orientation: 6 });
});
});
describe("readPhotoExif", () => { describe("readPhotoExif", () => {
it("reads the common fields of a valid JPEG", () => { it("reads the common fields of a valid JPEG", () => {
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6]; const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
orientation: 6, orientation: 6,
}); });
}); });
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => { it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => {
const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE]; const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE];
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
orientation: 6, orientation: 6,
}); });
}); });
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => { it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => {
const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF]; const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF];
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
gpsAltitude: 12.5, gpsAltitude: 12.5,
}); });
}); });
it("gives no make for a Make whose value lies past the end of the file", () => { it("gives no make for a Make whose value lies past the end of the file", () => {
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END]; const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END];
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ expect(
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
orientation: 6, orientation: 6,
}); });
}); });
@@ -175,8 +241,9 @@ describe("readPhotoExif", () => {
"an EXIF block that cannot be parsed", "an EXIF block that cannot be parsed",
bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS), bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS),
], ],
])("returns no fields for %s", (_, input) => { ])("returns no tags and no fields for %s", (_, input) => {
expect(readPhotoExif(input)).toStrictEqual({}); expect(readAllExifTags(input)).toStrictEqual({});
expect(readPhotoExif(readAllExifTags(input))).toStrictEqual({});
}); });
}); });
+6 -20
View File
@@ -29,6 +29,7 @@ import { downloadAlbums } from "../../examples/download-albums.js";
import { Library, type ContentSource } from "../../src/index.js"; import { Library, type ContentSource } from "../../src/index.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { readAllExifTags } from "../../src/exif.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { import {
asLivePhoto, asLivePhoto,
@@ -201,11 +202,10 @@ describe("examples/download-albums.ts", () => {
Buffer.from(VIDEO), Buffer.from(VIDEO),
); );
// The metadata is the photo's record and its EXIF fields. The // The metadata is the photo's record and its EXIF tags. The originals
// originals of photos 1 and 2 are not image data, so they have no // of photos 1 and 2 are not image data, so they have no EXIF tags.
// EXIF fields. Photo 3's image holds a camera, an exposure and a // Photo 3's are every tag of its image, as `photo.exif()` returns
// position, and the date it was taken is written as an ISO 8601 // them. `record` holds the fields the three records share.
// string. `record` holds the fields the three records share.
const record = { const record = {
takenAt: TAKEN_MS, takenAt: TAKEN_MS,
modifiedAt: TAKEN_MS, modifiedAt: TAKEN_MS,
@@ -234,21 +234,7 @@ describe("examples/download-albums.ts", () => {
title: "file-3.jpg", title: "file-3.jpg",
fileType: "livePhoto", fileType: "livePhoto",
hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO), hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO),
exif: { exif: readAllExifTags(HEIC_WITH_EXIF),
make: "Canon",
model: "EOS R5",
lensModel: "RF50mm F1.8 STM",
dateTimeOriginal: "2021-07-15T14:30:00.000Z",
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,
},
}); });
// Each album's photos, newest first, by save path relative to `dir`. // Each album's photos, newest first, by save path relative to `dir`.
+86 -25
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()`, `exif()` and the methods that each return one field of `exif()`. * `content()`, `exif()` and the methods that each return one EXIF field.
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
@@ -27,7 +27,7 @@ import { Library, type LibraryOptions } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js"; import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import type { PhotoExif } from "../../src/exif.js"; import { readPhotoExif, type PhotoExif } from "../../src/exif.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { import {
asLivePhoto, asLivePhoto,
@@ -273,10 +273,10 @@ const entry = (
value: number[], value: number[],
): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value]; ): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value];
// The TIFF block of a JPEG's EXIF segment, holding every field `exif()` picks: // The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed
// the camera in the first IFD, the exposure in the Exif IFD, and a GPS position // methods return: the camera in the first IFD, the exposure in the Exif IFD,
// of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. Offsets count from the // and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level.
// start of this block. // Offsets count from the start of this block.
const TIFF = [ const TIFF = [
...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF ...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF
...u32(8), // the first IFD's offset ...u32(8), // the first IFD's offset
@@ -637,9 +637,72 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
it("reads the common EXIF fields of a JPEG original", async () => { // Every tag in JPEG_WITH_EXIF, by name, in the order of its IFDs.
const jpegTags = [
"Make",
"Model",
"Orientation",
"Exif IFD Pointer",
"GPS Info IFD Pointer",
"ExposureTime",
"FNumber",
"ISOSpeedRatings",
"DateTimeOriginal",
"OffsetTimeOriginal",
"FocalLength",
"LensModel",
"GPSLatitudeRef",
"GPSLatitude",
"GPSLongitudeRef",
"GPSLongitude",
"GPSAltitudeRef",
"GPSAltitude",
];
// HEIC_WITH_EXIF holds those and the tags exiftool adds to every file.
const heicTags = [
...jpegTags,
"YCbCrPositioning",
"ExifVersion",
"ComponentsConfiguration",
"ColorSpace",
"GPSVersionID",
];
it.each([
[
"JPEG",
JPEG_WITH_EXIF,
jpegTags,
{
"Exif IFD Pointer": { value: 88 },
GPSLatitudeRef: { value: ["N"], description: "North latitude" },
},
],
[
"HEIC",
HEIC_WITH_EXIF,
heicTags,
{
ColorSpace: { value: 0xffff, description: "Uncalibrated" },
ExifVersion: { description: "0232" },
},
],
])(
"returns every EXIF tag of a %s original, by name",
async (_, bytes, names, others) => {
const lib = await open({ contentSource: stubSource(bytes) });
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(Object.keys(exif).sort()).toEqual([...names].sort());
// Tags outside the thirteen fields, as exifreader decodes them.
expect(exif).toMatchObject(others);
await lib.close();
},
);
it("picks the common EXIF fields from a JPEG original's tags", async () => {
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) }); const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({ const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(readPhotoExif(exif)).toStrictEqual({
make: "Canon", make: "Canon",
model: "EOS R5", model: "EOS R5",
lensModel: "RF50mm F1.8 STM", lensModel: "RF50mm F1.8 STM",
@@ -658,8 +721,8 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
// What exif() returns for HEIC_WITH_EXIF, and for JPEG_WITH_EXIF, which // The fields picked from HEIC_WITH_EXIF's tags, and from JPEG_WITH_EXIF's,
// holds the same values. // which hold the same values.
const heicFields: PhotoExif = { const heicFields: PhotoExif = {
make: "Canon", make: "Canon",
model: "EOS R5", model: "EOS R5",
@@ -676,11 +739,10 @@ describe("Photo save path, local copy, content and EXIF", () => {
gpsAltitude: -12.5, gpsAltitude: -12.5,
}; };
it("reads the same common EXIF fields from a HEIC original", async () => { it("picks the same common EXIF fields from a HEIC original's tags", async () => {
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) }); const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual( const exif = await lib.photos.byID({ fileID: 1 })!.exif();
heicFields, expect(readPhotoExif(exif)).toStrictEqual(heicFields);
);
await lib.close(); await lib.close();
}); });
@@ -694,28 +756,27 @@ describe("Photo save path, local copy, content and EXIF", () => {
client: new FilesClient([live]), client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])), contentSource: cdnSource(new Map([[1, body]])),
}); });
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual( const exif = await lib.photos.byID({ fileID: 1 })!.exif();
heicFields, expect(readPhotoExif(exif)).toStrictEqual(heicFields);
);
await lib.close(); await lib.close();
}); });
// The build's type check, not this test, makes sure `Photo` has a method // The build's type check, not this test, makes sure `Photo` has a method
// for every `PhotoExif` field, whatever the fixtures hold: `Photo` // for every `PhotoExif` field, whatever the fixtures hold: `Photo`
// implements a type with one method per field. This test checks that each // implements a type with one method per field. This test checks that each
// method gives the same value as exif(). // method gives the field picked from the tags exif() returns.
it.each([ it.each([
["JPEG", JPEG_WITH_EXIF], ["JPEG", JPEG_WITH_EXIF],
["HEIC", HEIC_WITH_EXIF], ["HEIC", HEIC_WITH_EXIF],
])( ])(
"has a method for each field exif() returns, giving the same value, for a %s", "has a method for each field, agreeing with the tags exif() returns, for a %s",
async (_, bytes) => { async (_, bytes) => {
const lib = await open({ contentSource: stubSource(bytes) }); const lib = await open({ contentSource: stubSource(bytes) });
const photo = lib.photos.byID({ fileID: 1 })!; const photo = lib.photos.byID({ fileID: 1 })!;
const exif = await photo.exif(); const fields = readPhotoExif(await photo.exif());
// The file holds every field, so every method is checked. // The file holds every field, so every method is checked.
expect(exif).toStrictEqual(heicFields); expect(fields).toStrictEqual(heicFields);
for (const [field, value] of Object.entries(exif)) { for (const [field, value] of Object.entries(fields)) {
expect(await photo[field as keyof PhotoExif]()).toStrictEqual( expect(await photo[field as keyof PhotoExif]()).toStrictEqual(
value, value,
); );
@@ -724,7 +785,7 @@ describe("Photo save path, local copy, content and EXIF", () => {
}, },
); );
it("returns no EXIF fields for an original that is not an image", async () => { it("returns no EXIF tags for an original that is not an image", async () => {
const lib = await open(); const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!; const photo = lib.photos.byID({ fileID: 1 })!;
expect(await photo.exif()).toStrictEqual({}); expect(await photo.exif()).toStrictEqual({});
@@ -732,7 +793,7 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
it("returns no EXIF fields for a JPEG whose EXIF cannot be parsed", async () => { it("returns no EXIF tags for a JPEG whose EXIF cannot be parsed", async () => {
const lib = await open({ const lib = await open({
contentSource: stubSource(JPEG_WITH_BAD_EXIF), contentSource: stubSource(JPEG_WITH_BAD_EXIF),
}); });
@@ -753,7 +814,7 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
it("returns no EXIF fields for a video, without fetching it", async () => { it("returns no EXIF tags for a video, without fetching it", async () => {
const video = file(1, 1); const video = file(1, 1);
video.metadata.fileType = "video"; video.metadata.fileType = "video";
const source = stubSource(JPEG_WITH_EXIF); const source = stubSource(JPEG_WITH_EXIF);