exif() returns every EXIF tag in the file (closes #156)
check / check (push) Failing after 59s
check / check (push) Failing after 59s
`photo.exif()` now returns every EXIF tag in the file, as the owner ruled, typed `ExifTags`: each tag keyed by name with `exifreader`'s `id`, `value`, `description` and `computed`. A tag with no name is keyed `undefined-` plus its number, and the embedded thumbnail's tags sit under `Thumbnail`. The thirteen typed methods stay, with the same names and types; each now picks its field from `exif()`'s tags. GPS latitude and longitude are worked out from the GPS tags and their reference tags, and a position with no reference tags gives neither. `backup-metadata --exif` output is unchanged. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
This commit was merged in pull request #159.
This commit is contained in:
@@ -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,
|
||||
@@ -273,10 +273,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
|
||||
@@ -637,9 +637,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",
|
||||
@@ -658,8 +721,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",
|
||||
@@ -676,11 +739,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();
|
||||
});
|
||||
|
||||
@@ -694,28 +756,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,
|
||||
);
|
||||
@@ -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 photo = lib.photos.byID({ fileID: 1 })!;
|
||||
expect(await photo.exif()).toStrictEqual({});
|
||||
@@ -732,7 +793,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),
|
||||
});
|
||||
@@ -753,7 +814,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