exif() returns every EXIF tag in the file (closes #156)
check / check (push) Failing after 51s
check / check (push) Failing after 51s
`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:
@@ -3,14 +3,18 @@
|
||||
* `quak backup-metadata --exif` records.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||
|
||||
@@ -87,6 +91,27 @@ const TIFF_UNNAMED_TAG = [
|
||||
...[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.
|
||||
const app1 = (data: number[]): number[] => {
|
||||
const len = data.length + 2;
|
||||
@@ -96,31 +121,72 @@ const app1 = (data: number[]): number[] => {
|
||||
const bytes = (...parts: number[][]): Uint8Array =>
|
||||
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", () => {
|
||||
it("reads the common fields of a valid JPEG", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives no make for a Make whose value lies past the end of the file", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -175,8 +241,9 @@ describe("readPhotoExif", () => {
|
||||
"an EXIF block that cannot be parsed",
|
||||
bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS),
|
||||
],
|
||||
])("returns no fields for %s", (_, input) => {
|
||||
expect(readPhotoExif(input)).toStrictEqual({});
|
||||
])("returns no tags and no fields for %s", (_, input) => {
|
||||
expect(readAllExifTags(input)).toStrictEqual({});
|
||||
expect(readPhotoExif(readAllExifTags(input))).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { downloadAlbums } from "../../examples/download-albums.js";
|
||||
import { Library, type ContentSource } from "../../src/index.js";
|
||||
import type { CollectionsPage, FilesPage } from "../../src/client.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 {
|
||||
asLivePhoto,
|
||||
@@ -201,11 +202,10 @@ describe("examples/download-albums.ts", () => {
|
||||
Buffer.from(VIDEO),
|
||||
);
|
||||
|
||||
// The metadata is the photo's record and its EXIF fields. The
|
||||
// originals of photos 1 and 2 are not image data, so they have no
|
||||
// EXIF fields. Photo 3's image holds a camera, an exposure and a
|
||||
// position, and the date it was taken is written as an ISO 8601
|
||||
// string. `record` holds the fields the three records share.
|
||||
// The metadata is the photo's record and its EXIF tags. The originals
|
||||
// of photos 1 and 2 are not image data, so they have no EXIF tags.
|
||||
// Photo 3's are every tag of its image, as `photo.exif()` returns
|
||||
// them. `record` holds the fields the three records share.
|
||||
const record = {
|
||||
takenAt: TAKEN_MS,
|
||||
modifiedAt: TAKEN_MS,
|
||||
@@ -234,21 +234,7 @@ describe("examples/download-albums.ts", () => {
|
||||
title: "file-3.jpg",
|
||||
fileType: "livePhoto",
|
||||
hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO),
|
||||
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,
|
||||
},
|
||||
exif: readAllExifTags(HEIC_WITH_EXIF),
|
||||
});
|
||||
|
||||
// Each album's photos, newest first, by save path relative to `dir`.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 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. 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";
|
||||
@@ -27,7 +27,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 type { PhotoExif } from "../../src/exif.js";
|
||||
import { readPhotoExif, type PhotoExif } from "../../src/exif.js";
|
||||
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||
import {
|
||||
asLivePhoto,
|
||||
@@ -259,10 +259,10 @@ const entry = (
|
||||
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.
|
||||
// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed
|
||||
// methods return: 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
|
||||
@@ -623,9 +623,72 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
||||
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) });
|
||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({
|
||||
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
||||
expect(readPhotoExif(exif)).toStrictEqual({
|
||||
make: "Canon",
|
||||
model: "EOS R5",
|
||||
lensModel: "RF50mm F1.8 STM",
|
||||
@@ -644,8 +707,8 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
// What exif() returns for HEIC_WITH_EXIF, and for JPEG_WITH_EXIF, which
|
||||
// holds the same values.
|
||||
// The fields picked from HEIC_WITH_EXIF's tags, and from JPEG_WITH_EXIF's,
|
||||
// which hold the same values.
|
||||
const heicFields: PhotoExif = {
|
||||
make: "Canon",
|
||||
model: "EOS R5",
|
||||
@@ -662,11 +725,10 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
||||
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) });
|
||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual(
|
||||
heicFields,
|
||||
);
|
||||
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
||||
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
@@ -680,28 +742,27 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
||||
client: new FilesClient([live]),
|
||||
contentSource: cdnSource(new Map([[1, body]])),
|
||||
});
|
||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual(
|
||||
heicFields,
|
||||
);
|
||||
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
||||
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
|
||||
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().
|
||||
// method gives the field picked from the tags exif() returns.
|
||||
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",
|
||||
"has a method for each field, agreeing with the tags exif() returns, for a %s",
|
||||
async (_, bytes) => {
|
||||
const lib = await open({ contentSource: stubSource(bytes) });
|
||||
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.
|
||||
expect(exif).toStrictEqual(heicFields);
|
||||
for (const [field, value] of Object.entries(exif)) {
|
||||
expect(fields).toStrictEqual(heicFields);
|
||||
for (const [field, value] of Object.entries(fields)) {
|
||||
expect(await photo[field as keyof PhotoExif]()).toStrictEqual(
|
||||
value,
|
||||
);
|
||||
@@ -710,7 +771,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 photo = lib.photos.byID({ fileID: 1 })!;
|
||||
expect(await photo.exif()).toStrictEqual({});
|
||||
@@ -718,7 +779,7 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
||||
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({
|
||||
contentSource: stubSource(JPEG_WITH_BAD_EXIF),
|
||||
});
|
||||
@@ -739,7 +800,7 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
||||
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);
|
||||
video.metadata.fileType = "video";
|
||||
const source = stubSource(JPEG_WITH_EXIF);
|
||||
|
||||
Reference in New Issue
Block a user