exif(): read HEIF/HEIC originals with exifreader (closes #145)
check / check (push) Successful in 1m21s

photo.exif() and backup-metadata --exif read EXIF with exifreader,
which reads JPEG, HEIC/HEIF, AVIF, PNG and WebP, so a HEIC original or
a live photo's HEIC image gets its EXIF. exifreader replaces
exif-reader and the hand-written JPEG segment scan. PhotoExif is
unchanged. The exif field of backup-metadata --exif is now
exifreader's tag output, and exifRaw holds the whole EXIF block it
could not read. Tests use a real 933-byte HEIC fixture, test/exif.heic,
whose recipe is in test/exif-heic.ts.

Model: opus-5-5
This commit is contained in:
2026-10-01 18:22:00 +00:00
parent 2b598d3622
commit a13ec66bca
12 changed files with 307 additions and 206 deletions
+54 -3
View File
@@ -25,7 +25,16 @@ 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, IMAGE, livePhotoZip } from "../live-photo.js";
import type { PhotoExif } from "../../src/exif.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import {
asLivePhoto,
cdnSource,
IMAGE,
livePhotoHash,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
const USER_ID = 7;
@@ -297,7 +306,7 @@ const JPEG_WITH_EXIF = new Uint8Array([
]);
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
// byte order belongs, so exif-reader cannot parse it.
// byte order belongs, so exifreader cannot parse it.
const JPEG_WITH_BAD_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
@@ -411,7 +420,49 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close();
});
it("returns no EXIF fields for an original that is not a JPEG", async () => {
// What exif() returns for HEIC_WITH_EXIF, which holds the same values as
// JPEG_WITH_EXIF.
const heicFields: PhotoExif = {
make: "Canon",
model: "EOS R5",
lensModel: "RF50mm F1.8 STM",
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,
};
it("reads the same common EXIF fields from a HEIC original", async () => {
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual(
heicFields,
);
await lib.close();
});
it("reads the EXIF of a live photo whose image is a HEIC", async () => {
const { file: live, body } = await asLivePhoto(
file(1, 1),
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
);
const lib = await open({
client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])),
});
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual(
heicFields,
);
await lib.close();
});
it("returns no EXIF fields for an original that is not an image", async () => {
const lib = await open();
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
await lib.close();