Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m34s
check / check (push) Successful in 1m34s
A Photo now has savePath and isLocal, which look only at the disk; content() and exif(), which may download the original; and modifiedAt, hash, fileSize and year. PhotoRecord gains modifiedAt, hash and fileSize. The JPEG EXIF scan moves from metadata-backup.ts to the new src/exif.ts, so the read surface does not import the backup command. Model: opus-5-5
This commit is contained in:
@@ -8,10 +8,8 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractExifFromJpeg,
|
||||
extractImageMetadata,
|
||||
} from "../../src/metadata-backup.js";
|
||||
import { extractExifFromJpeg } from "../../src/exif.js";
|
||||
import { extractImageMetadata } from "../../src/metadata-backup.js";
|
||||
|
||||
const SOI = [0xff, 0xd8]; // start of image
|
||||
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
|
||||
|
||||
@@ -6,7 +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.
|
||||
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()` and
|
||||
* `exif()`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
@@ -24,7 +25,7 @@ import { Library, type LibraryOptions } from "../../src/library/index.js";
|
||||
import type { ContentSource } from "../../src/library/content.js";
|
||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||
import { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js";
|
||||
import { asLivePhoto, cdnSource, IMAGE, livePhotoZip } from "../live-photo.js";
|
||||
|
||||
const USER_ID = 7;
|
||||
|
||||
@@ -70,14 +71,23 @@ class MockClient {
|
||||
}
|
||||
}
|
||||
|
||||
// A content source that writes a marker file and counts thumbnail fetches.
|
||||
const stubSource = (): ContentSource & { thumbCalls: () => number } => {
|
||||
// A content source that writes `original` as every original and a marker file
|
||||
// as every thumbnail, and counts the fetches of each.
|
||||
const stubSource = (
|
||||
original: string | Uint8Array = "orig-bytes",
|
||||
): ContentSource & {
|
||||
originalCalls: () => number;
|
||||
thumbCalls: () => number;
|
||||
} => {
|
||||
let originalCalls = 0;
|
||||
let thumbCalls = 0;
|
||||
return {
|
||||
originalCalls: () => originalCalls,
|
||||
thumbCalls: () => thumbCalls,
|
||||
original: async ({ destination }) => {
|
||||
writeFileSync(destination, "orig-bytes");
|
||||
return { bytesWritten: 10 };
|
||||
originalCalls++;
|
||||
writeFileSync(destination, original);
|
||||
return { bytesWritten: original.length };
|
||||
},
|
||||
thumbnail: async ({ destination }) => {
|
||||
thumbCalls++;
|
||||
@@ -211,3 +221,202 @@ describe("Library content wiring", () => {
|
||||
await lib.close();
|
||||
});
|
||||
});
|
||||
|
||||
// Big-endian bytes for the hand-built JPEG below.
|
||||
const u16 = (n: number): number[] => [n >> 8, n & 0xff];
|
||||
const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)];
|
||||
const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0];
|
||||
const rational = (num: number, den: number): number[] => [
|
||||
...u32(num),
|
||||
...u32(den),
|
||||
];
|
||||
// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL),
|
||||
// count, then the value when it fits in 4 bytes, else its offset.
|
||||
const entry = (
|
||||
tag: number,
|
||||
type: number,
|
||||
count: number,
|
||||
value: number[],
|
||||
): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value];
|
||||
|
||||
// The TIFF block of a JPEG's EXIF segment, holding every field `exif()` picks:
|
||||
// the camera in the first IFD, the exposure in the Exif IFD, and a GPS position
|
||||
// of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. Offsets count from the
|
||||
// start of this block.
|
||||
const TIFF = [
|
||||
...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF
|
||||
...u32(8), // the first IFD's offset
|
||||
// The first IFD, at 8: five entries, then no next IFD.
|
||||
...u16(5),
|
||||
...entry(0x010f, 2, 6, u32(74)), // Make
|
||||
...entry(0x0110, 2, 7, u32(80)), // Model
|
||||
...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation
|
||||
...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset
|
||||
...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset
|
||||
...u32(0),
|
||||
...ascii("Canon"), // at 74
|
||||
...ascii("EOS R5"), // at 80
|
||||
0, // a pad byte
|
||||
// The Exif IFD, at 88: seven entries, then no next IFD.
|
||||
...u16(7),
|
||||
...entry(0x829a, 5, 1, u32(178)), // ExposureTime
|
||||
...entry(0x829d, 5, 1, u32(186)), // FNumber
|
||||
...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings
|
||||
...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal
|
||||
...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal
|
||||
...entry(0x920a, 5, 1, u32(222)), // FocalLength
|
||||
...entry(0xa434, 2, 16, u32(230)), // LensModel
|
||||
...u32(0),
|
||||
...rational(1, 250), // at 178
|
||||
...rational(28, 10), // at 186
|
||||
...ascii("2021:07:15 14:30:00"), // at 194
|
||||
...ascii("+02:00"), // at 214
|
||||
0, // a pad byte
|
||||
...rational(50, 1), // at 222
|
||||
...ascii("RF50mm F1.8 STM"), // at 230
|
||||
// The GPS IFD, at 246: six entries, then no next IFD.
|
||||
...u16(6),
|
||||
...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef
|
||||
...entry(0x0002, 5, 3, u32(324)), // GPSLatitude
|
||||
...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef
|
||||
...entry(0x0004, 5, 3, u32(348)), // GPSLongitude
|
||||
...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level
|
||||
...entry(0x0006, 5, 1, u32(372)), // GPSAltitude
|
||||
...u32(0),
|
||||
...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324
|
||||
...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348
|
||||
...rational(25, 2), // at 372
|
||||
];
|
||||
|
||||
const JPEG_WITH_EXIF = new Uint8Array([
|
||||
...[0xff, 0xd8], // start of image
|
||||
...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length
|
||||
...[...ascii("Exif"), 0], // "Exif\0\0"
|
||||
...TIFF,
|
||||
...[0xff, 0xda, 0x00, 0x02], // start of scan
|
||||
]);
|
||||
|
||||
describe("Photo save path, local copy, content and EXIF", () => {
|
||||
// The same account, with `files` in its album instead.
|
||||
class FilesClient extends MockClient {
|
||||
constructor(private readonly files: EnteFile[]) {
|
||||
super();
|
||||
}
|
||||
override async filesSince(): Promise<FilesPage> {
|
||||
return { files: this.files, deleted: [], cursor: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
const open = (opts: Partial<LibraryOptions> = {}): Promise<Library> =>
|
||||
Library.open({
|
||||
client: new MockClient(),
|
||||
cacheDirectory: join(root, "cache"),
|
||||
downloadDirectory: join(root, "backup"),
|
||||
contentSource: stubSource(),
|
||||
refreshIntervalSeconds: 3600,
|
||||
precacheThumbnails: false,
|
||||
precacheOriginals: false,
|
||||
...opts,
|
||||
});
|
||||
|
||||
it("names where a backup writes the original, which is local once the backup has written it", async () => {
|
||||
const lib = await open();
|
||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||
const savePath = join(root, "backup", "originals", "1.jpg");
|
||||
expect(photo.savePath).toBe(savePath);
|
||||
expect(photo.isLocal).toBe(false);
|
||||
|
||||
await lib.backup();
|
||||
expect(photo.savePath).toBe(savePath);
|
||||
expect(existsSync(savePath)).toBe(true);
|
||||
expect(photo.isLocal).toBe(true);
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("returns the original's bytes, and a copy only in the cache is not local", async () => {
|
||||
const lib = await open();
|
||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||
expect(await photo.content()).toEqual(Buffer.from("orig-bytes"));
|
||||
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(existsSync(photo.savePath!)).toBe(false);
|
||||
expect(photo.isLocal).toBe(false);
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("has no save path and is not local without a download directory", async () => {
|
||||
const lib = await open({ downloadDirectory: undefined });
|
||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||
expect(photo.savePath).toBeUndefined();
|
||||
expect(photo.isLocal).toBe(false);
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("has no save path and is not local without a content source", async () => {
|
||||
const lib = await open({ contentSource: undefined });
|
||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||
expect(photo.savePath).toBeUndefined();
|
||||
expect(photo.isLocal).toBe(false);
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("gives a live photo's image as its save path once a backup has stored it", async () => {
|
||||
const { file: live, body } = await asLivePhoto(file(1, 1));
|
||||
const lib = await open({
|
||||
client: new FilesClient([live]),
|
||||
contentSource: cdnSource(new Map([[1, body]])),
|
||||
});
|
||||
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||
const originals = join(root, "backup", "originals");
|
||||
// Until then the name comes from the title, file-1.jpg; the backup
|
||||
// stores the image with the extension found inside the live photo.
|
||||
expect(photo.savePath).toBe(join(originals, "1.jpg"));
|
||||
|
||||
await lib.backup();
|
||||
expect(photo.savePath).toBe(join(originals, "1.heic"));
|
||||
expect(photo.isLocal).toBe(true);
|
||||
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("reads the common EXIF fields of a JPEG original", async () => {
|
||||
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
|
||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({
|
||||
make: "Canon",
|
||||
model: "EOS R5",
|
||||
lensModel: "RF50mm F1.8 STM",
|
||||
// The camera's clock reading, held in the Date's UTC fields.
|
||||
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
|
||||
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,
|
||||
});
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("returns no EXIF fields for an original that is not a JPEG", async () => {
|
||||
const lib = await open();
|
||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("returns no EXIF fields for a video, without fetching it", async () => {
|
||||
const video = file(1, 1);
|
||||
video.metadata.fileType = "video";
|
||||
const source = stubSource(JPEG_WITH_EXIF);
|
||||
const lib = await open({
|
||||
client: new FilesClient([video]),
|
||||
contentSource: source,
|
||||
});
|
||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
|
||||
expect(source.originalCalls()).toBe(0);
|
||||
await lib.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,6 +209,31 @@ describe("lib.photos", () => {
|
||||
expect("key" in photo.record()).toBe(false);
|
||||
});
|
||||
|
||||
it("byID exposes modifiedAt, hash, fileSize and the year taken", () => {
|
||||
// Mid-July, so the year is 2021 in every time zone.
|
||||
const takenAt = Date.UTC(2021, 6, 15, 12);
|
||||
const records = deriveRecords(
|
||||
[collection(1)],
|
||||
[
|
||||
file(1001, 1, {
|
||||
metadata: {
|
||||
title: "IMG.jpg",
|
||||
fileType: "image",
|
||||
creationTime: micros(takenAt),
|
||||
modificationTime: micros(1_700_000_123_456),
|
||||
hash: "aGFzaA==",
|
||||
},
|
||||
file: { decryptionHeader: "aGVhZGVy", size: 2_048_000 },
|
||||
}),
|
||||
],
|
||||
);
|
||||
const photo = apis(records).photos.byID({ fileID: 1001 })!;
|
||||
expect(photo.modifiedAt).toBe(ms(1_700_000_123_456));
|
||||
expect(photo.hash).toBe("aGFzaA==");
|
||||
expect(photo.fileSize).toBe(2_048_000);
|
||||
expect(photo.year).toBe(2021);
|
||||
});
|
||||
|
||||
it("byID returns undefined for an unknown file id", () => {
|
||||
const records = deriveRecords([collection(1)], [file(1, 1)]);
|
||||
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
|
||||
|
||||
@@ -159,6 +159,8 @@ describe("deriveRecords: photo mapping", () => {
|
||||
expect("width" in rec).toBe(false);
|
||||
expect("height" in rec).toBe(false);
|
||||
expect("latitude" in rec).toBe(false);
|
||||
expect("hash" in rec).toBe(false);
|
||||
expect("fileSize" in rec).toBe(false);
|
||||
});
|
||||
|
||||
it("reads archived and hidden from private magicMetadata.visibility", () => {
|
||||
|
||||
Reference in New Issue
Block a user