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
+13 -5
View File
@@ -224,7 +224,7 @@ quak/
backup.ts resilient full-account backup with dedup backup.ts resilient full-account backup with dedup
metadata-backup.ts metadata-backup.ts
backup-metadata: the metadata quak keeps, as JSON backup-metadata: the metadata quak keeps, as JSON
exif.ts EXIF read from a JPEG's bytes exif.ts EXIF read from an image's bytes with exifreader
mldata-fetch.ts fetch + decrypt per-file ML data mldata-fetch.ts fetch + decrypt per-file ML data
filename.ts safe file names from server metadata filename.ts safe file names from server metadata
errors.ts error types shared across layers errors.ts error types shared across layers
@@ -495,8 +495,15 @@ Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
`--out PATH`, the image is written to `PATH` and the video beside it, with `--out PATH`, the image is written to `PATH` and the video beside it, with
`PATH`'s name and the video's extension; a `PATH` with the video's extension is `PATH`'s name and the video's extension; a `PATH` with the video's extension is
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
file's original through the cache and records its XMP metadata and, for a JPEG, file's original through the cache and records, from it or a live photo's image,
its EXIF metadata and dimensions. `collections`, `files`, `backup`, its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is
read with [exifreader](https://github.com/mattiasw/ExifReader) from any image
format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's
`exif` field is exifreader's EXIF tag output: each tag by name, with its
`value`, `description` and `computed` value. Dumps written before 2026-10-01
hold `exif-reader`'s `Image`, `Photo` and `GPSInfo` groups there instead. An
EXIF block exifreader finds but reads no tag from is recorded, base64, as
`exifRaw`, with the reason in `exifError`. `collections`, `files`, `backup`,
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take `helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take
`--json` for machine-readable output. `--json` for machine-readable output.
@@ -720,8 +727,9 @@ Four async methods may download:
each absent when the file lacks it. GPS values are signed decimal degrees and each absent when the file lacks it. GPS values are signed decimal degrees and
metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s 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 fields; `offsetTimeOriginal`, when present, is that clock's offset from
UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video UTC. EXIF is read from any image format exifreader reads: JPEG, HEIC/HEIF,
gives `{}` without being downloaded. AVIF, PNG and WebP, a live photo's image included. Any other original gives
`{}`, and a video gives `{}` without being downloaded.
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
+8
View File
@@ -25,6 +25,14 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
the other image formats `exifreader` reads (issue 145). `exifreader` replaces
`exif-reader` and the 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. The tests use a real
HEIC, `test/exif.heic`.
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`, - 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where `modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
`lib.backup()` writes the original under the library's download directory; for `lib.backup()` writes the original under the library's download directory; for
+1 -1
View File
@@ -46,7 +46,7 @@
"@inquirer/prompts": "8.5.2", "@inquirer/prompts": "8.5.2",
"commander": "14.0.3", "commander": "14.0.3",
"env-paths": "4.0.0", "env-paths": "4.0.0",
"exif-reader": "2.0.3", "exifreader": "4.46.0",
"fast-srp-hap": "2.0.4", "fast-srp-hap": "2.0.4",
"fflate": "0.8.3", "fflate": "0.8.3",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
+55 -99
View File
@@ -1,60 +1,29 @@
// EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block // EXIF in an original's bytes, read with exifreader, which reads it from JPEG,
// it finds; `Photo.exif()` returns the common fields picked from it here. // HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports.
// `backup-metadata --exif` records every EXIF tag it finds; `Photo.exif()`
// returns the common fields picked from them here.
import exifReader from "exif-reader"; import ExifReader, { type ExpandedTags } from "exifreader";
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment // The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from
// data, starting at the "Exif\0\0" header) when there is one, nothing when the // them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`).
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is // Undefined when exifreader cannot read the file at all, such as a video. An
// malformed. Each segment length is checked against the bytes that remain and // EXIF block it finds but reads no tag from comes back as an empty `exif`.
// each step moves forward by at least 4 bytes, so the scan ends on any input. // Each tag's `computed` holds its value as a plain string or number.
export const extractExifFromJpeg = ( export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
buf: Uint8Array, try {
): { exif?: Buffer; error?: string } => { return ExifReader.loadView(
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {}; new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
let offset = 2; {
while (offset < buf.length) { expanded: true,
if (offset + 2 > buf.length) computed: true,
return { error: `truncated segment marker at byte ${offset}` }; includeOffsets: true,
if (buf[offset] !== 0xff) includeTags: { exif: true, gps: true },
return { error: `no segment marker at byte ${offset}` }; },
const marker = buf[offset + 1]!; );
if (marker === 0xda) return {}; // start of scan, no more markers } catch {
if (offset + 4 > buf.length) return undefined;
return { error: `truncated segment length at byte ${offset}` };
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
// The length counts its own two bytes, so anything under 2 is invalid.
if (len < 2)
return {
error: `segment length ${len} at byte ${offset} is too small`,
};
if (offset + 2 + len > buf.length)
return {
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
};
if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
// the six-byte header, so the segment is not EXIF; below 6 the
// bytes compared would also lie past the segment.
if (
len >= 8 &&
buf[offset + 4] === 0x45 &&
buf[offset + 5] === 0x78 &&
buf[offset + 6] === 0x69 &&
buf[offset + 7] === 0x66
) {
return {
exif: Buffer.from(
buf.buffer,
buf.byteOffset + offset + 4,
len - 2,
),
};
}
}
offset += 2 + len;
} }
return { error: "file ends before the image data" };
}; };
// The common EXIF fields of an original. Each is absent when the file lacks it. // The common EXIF fields of an original. Each is absent when the file lacks it.
@@ -63,9 +32,9 @@ export interface PhotoExif {
model?: string; model?: string;
lensModel?: string; lensModel?: string;
// When the photo was taken, by the camera's clock. EXIF writes this as text // When the photo was taken, by the camera's clock. EXIF writes this as text
// with no time zone, and exif-reader reads that text as if it were UTC: the // with no time zone, and it is read as if it were UTC: the Date's UTC
// Date's UTC fields are the clock reading, which is the moment it was taken // fields are the clock reading, which is the moment it was taken only when
// only when the clock was set to UTC. // the clock was set to UTC.
dateTimeOriginal?: Date; dateTimeOriginal?: Date;
// The camera clock's offset from UTC, such as "+02:00". // The camera clock's offset from UTC, such as "+02:00".
offsetTimeOriginal?: string; offsetTimeOriginal?: string;
@@ -90,52 +59,39 @@ const asString = (v: unknown): string | undefined =>
const asNumber = (v: unknown): number | undefined => const asNumber = (v: unknown): number | undefined =>
typeof v === "number" && Number.isFinite(v) ? v : undefined; typeof v === "number" && Number.isFinite(v) ? v : undefined;
const asDate = (v: unknown): Date | undefined => // EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading
v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined; // in a Date's UTC fields.
const asDate = (v: unknown): Date | undefined => {
// EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds. const m =
// This is them as decimal degrees, negated when `negative`. typeof v === "string"
const asDegrees = (v: unknown, negative: boolean): number | undefined => { ? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v)
if (!Array.isArray(v) || v.length !== 3) return undefined; : null;
const [d, m, s] = v.map(asNumber); if (!m) return undefined;
if (d === undefined || m === undefined || s === undefined) return undefined; const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`);
const degrees = d + m / 60 + s / 3600; return Number.isNaN(date.getTime()) ? undefined : date;
return negative ? -degrees : degrees;
}; };
// The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG, // The common fields of an original's EXIF: `{}` when the file has no EXIF,
// have no EXIF block, or exif-reader cannot parse it. // exifreader cannot read its EXIF, or it is not an image exifreader reads.
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { export const readPhotoExif = (bytes: Uint8Array): PhotoExif => {
const { exif } = extractExifFromJpeg(bytes); const tags = readExifTags(bytes);
if (exif === undefined) return {}; const exif = tags?.exif;
let tags: ReturnType<typeof exifReader>; const gps = tags?.gps;
try {
tags = exifReader(exif);
} catch {
return {};
}
const image = tags.Image ?? {};
const photo = tags.Photo ?? {};
const gps = tags.GPSInfo ?? {};
const altitude = asNumber(gps.GPSAltitude);
const fields: PhotoExif = { const fields: PhotoExif = {
make: asString(image.Make), make: asString(exif?.Make?.computed),
model: asString(image.Model), model: asString(exif?.Model?.computed),
lensModel: asString(photo.LensModel), lensModel: asString(exif?.LensModel?.computed),
dateTimeOriginal: asDate(photo.DateTimeOriginal), dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed),
offsetTimeOriginal: asString(photo.OffsetTimeOriginal), offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed),
exposureTime: asNumber(photo.ExposureTime), exposureTime: asNumber(exif?.ExposureTime?.computed),
fNumber: asNumber(photo.FNumber), fNumber: asNumber(exif?.FNumber?.computed),
iso: asNumber(photo.ISOSpeedRatings), // Only when the tag holds a single number, as most cameras write it.
focalLength: asNumber(photo.FocalLength), iso: asNumber(exif?.ISOSpeedRatings?.computed),
orientation: asNumber(image.Orientation), focalLength: asNumber(exif?.FocalLength?.computed),
gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"), orientation: asNumber(exif?.Orientation?.computed),
gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"), gpsLatitude: asNumber(gps?.Latitude),
// A GPSAltitudeRef of 1 means the altitude is below sea level. gpsLongitude: asNumber(gps?.Longitude),
gpsAltitude: gpsAltitude: asNumber(gps?.Altitude),
altitude !== undefined && gps.GPSAltitudeRef === 1
? -altitude
: altitude,
}; };
// Leave out what the file lacks, so a missing field is absent rather than // Leave out what the file lacks, so a missing field is absent rather than
// present and undefined. // present and undefined.
+4 -3
View File
@@ -130,9 +130,10 @@ export class Photo {
} }
// The common EXIF fields of the original, read from `content()`, so this // The common EXIF fields of the original, read from `content()`, so this
// may download it. Only a JPEG's EXIF is read; any other file gives `{}`, // may download it. EXIF is read from any image format exifreader reads,
// and a video gives it without fetching anything. Like the other content // JPEG and HEIC/HEIF among them; any other file gives `{}`, and a video
// methods, it throws when there is no content cache, video or not. // gives it without fetching anything. Like the other content methods, it
// throws when there is no content cache, video or not.
async exif(opts?: ContentOptions): Promise<PhotoExif> { async exif(opts?: ContentOptions): Promise<PhotoExif> {
this.cacheOrThrow(); this.cacheOrThrow();
if (this.rec.fileType === "video") return {}; if (this.rec.fileType === "video") return {};
+16 -14
View File
@@ -1,9 +1,8 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import * as jpeg from "jpeg-js"; import * as jpeg from "jpeg-js";
import exifReader from "exif-reader";
import type { Client } from "./client.js"; import type { Client } from "./client.js";
import { extractExifFromJpeg } from "./exif.js"; import { readExifTags } from "./exif.js";
import type { Library, Photo } from "./library/index.js"; import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js"; import { sanitizeFileName } from "./filename.js";
import { import {
@@ -20,9 +19,10 @@ export interface MetadataBackupOptions {
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
} }
// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment // Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags
// is malformed or cannot be parsed, the record carries the reason in // exifreader returns, from any image format it reads. When it finds an EXIF
// `exifError`. // block but reads no tag from it, the record keeps the block's bytes, base64,
// in `exifRaw`, with the reason in `exifError`.
export const extractImageMetadata = ( export const extractImageMetadata = (
fileBytes: Uint8Array, fileBytes: Uint8Array,
): Record<string, unknown> | undefined => { ): Record<string, unknown> | undefined => {
@@ -39,19 +39,21 @@ export const extractImageMetadata = (
result.height = decoded.height; result.height = decoded.height;
} catch { } catch {
// Not every original is a JPEG (PNG, HEIC, video), so a failed decode // Not every original is a JPEG (PNG, HEIC, video), so a failed decode
// is expected and only means no dimensions; a malformed JPEG is still // is expected and only means no dimensions; unreadable EXIF is still
// reported below through `exifError`. // reported below through `exifError`.
} }
const { exif, error } = extractExifFromJpeg(fileBytes); const tags = readExifTags(fileBytes);
if (error) result.exifError = error; if (tags?.exif && Object.keys(tags.exif).length > 0) {
if (exif) { result.exif = tags.exif;
try { } else if (tags?.exif) {
result.exif = exifReader(exif); const block = tags.metadataRange?.blocks.find((b) => b.type === "exif");
} catch (err) { if (block) {
result.exifRaw = exif.toString("base64"); result.exifRaw = Buffer.from(
result.exifError = err instanceof Error ? err.message : String(err); fileBytes.subarray(block.start, block.end),
).toString("base64");
} }
result.exifError = "no tag could be read from the EXIF block";
} }
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes) // Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
+26
View File
@@ -56,6 +56,7 @@ import type { ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { init, toBase64 } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { import {
asLivePhoto, asLivePhoto,
cdnSource, cdnSource,
@@ -653,6 +654,31 @@ describe("a live photo", () => {
height: 4, height: 4,
}); });
}); });
it("backup-metadata --exif records the EXIF of a HEIC image", async () => {
const dir = join(root, "dump");
expect(
await backupMetadataCommand(
context(await livePhotoClient(HEIC_WITH_EXIF)),
dir,
{ exif: true },
),
).toBe(0);
const record = JSON.parse(
readFileSync(
join(dir, "collections", "1-Vacation", "300.json"),
"utf-8",
),
);
expect(record.imageMetadata.exifError).toBeUndefined();
expect(record.imageMetadata.exif).toMatchObject({
Make: { value: ["Canon"] },
Model: { value: ["EOS R5"] },
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
});
});
}); });
describe("backup", () => { describe("backup", () => {
+92 -77
View File
@@ -1,19 +1,24 @@
/** /**
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`. * Tests for reading EXIF (`src/exif.ts`) and the image metadata
* `quak backup-metadata --exif` records.
* *
* The originals come from users' libraries, so a truncated or corrupt JPEG * The originals come from users' libraries, so a truncated or corrupt file
* must neither hang the scan nor throw out of it, and a malformed file must be * must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`,
* told apart from one that simply has no EXIF: the record carries the reason in * and `backup-metadata` tells an EXIF block it cannot read apart from a file
* `exifError`. Each input below is a short hand-built byte array. * 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 { describe, expect, it } from "vitest";
import { extractExifFromJpeg } from "../../src/exif.js"; import { 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";
const SOI = [0xff, 0xd8]; // start of image const SOI = [0xff, 0xd8]; // start of image
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0" const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00];
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6. // A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
const TIFF_ORIENTATION_6 = [ const TIFF_ORIENTATION_6 = [
@@ -31,74 +36,66 @@ const app1 = (data: number[]): number[] => {
const bytes = (...parts: number[][]): Uint8Array => const bytes = (...parts: number[][]): Uint8Array =>
new Uint8Array(parts.flat()); new Uint8Array(parts.flat());
describe("extractExifFromJpeg", () => { describe("readPhotoExif", () => {
it("returns the EXIF segment 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];
const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS)); expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
expect(scan.error).toBeUndefined(); orientation: 6,
expect([...scan.exif!]).toEqual(data); });
}); });
it("returns nothing for a file that is not a JPEG", () => { it.each([
const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); [
expect(extractExifFromJpeg(png)).toEqual({}); "a file that is not an image",
}); new TextEncoder().encode("just some text, not an image"),
],
it("returns nothing for a JPEG without EXIF", () => { [
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; "a PNG without EXIF",
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({}); bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
}); ],
["a JPEG without EXIF", bytes(SOI, APP0, SOS)],
it("ignores an APP1 segment too short to hold the Exif header", () => { // A length under 8 cannot hold the six-byte "Exif\0\0" header. This one
// A length under 8 cannot hold the six-byte "Exif\0\0" header, so the // has length 7 and holds only "Exif\0", so a read past its end would
// segment is not EXIF. This one has length 7 and holds only "Exif\0", // take the next segment's bytes as EXIF.
// which the old code, lacking the length check, returned as EXIF. [
const short = app1(EXIF_HEADER.slice(0, 5)); "an APP1 segment too short to hold the Exif header",
expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({}); bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS),
}); ],
[
it("accepts an APP1 segment of length 8 holding just the Exif header", () => { "an APP1 segment holding just the Exif header",
const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS)); bytes(SOI, app1(EXIF_HEADER), SOS),
expect(scan.error).toBeUndefined(); ],
expect([...scan.exif!]).toEqual(EXIF_HEADER); [
}); "a JPEG truncated inside a segment header",
bytes(SOI, [0xff, 0xe1, 0x00]),
it("reports a JPEG truncated inside a segment header", () => { ],
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00])); ["a JPEG that ends before the image data", bytes(SOI, APP0)],
expect(scan.exif).toBeUndefined(); // A length of 0 would step a scan by 2 bytes at a time through the
expect(scan.error).toMatch(/truncated segment length/); // rest of the file, reading garbage as markers.
}); [
"a zero-length segment",
it("reports a JPEG that ends before the image data", () => { bytes(
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; SOI,
const scan = extractExifFromJpeg(bytes(SOI, app0)); ZERO_LENGTH_APP0,
expect(scan.error).toMatch(/ends before the image data/); ZERO_LENGTH_APP0,
}); ZERO_LENGTH_APP0,
ZERO_LENGTH_APP0,
it("stops on a zero-length segment instead of looping", () => { SOS,
// A length of 0 would otherwise step the scan by 2 bytes at a time ),
// through the rest of the file, reading garbage as markers. ],
const zero = [0xff, 0xe0, 0x00, 0x00]; ["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)],
const scan = extractExifFromJpeg(
bytes(SOI, zero, zero, zero, zero, SOS),
);
expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
});
it("stops on a segment length of 1", () => {
const scan = extractExifFromJpeg(
bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
);
expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/);
});
it("reports a segment length that runs past the end of the file", () => {
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows. // APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
const scan = extractExifFromJpeg( [
"a segment length that runs past the end of the file",
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
); ],
expect(scan.exif).toBeUndefined(); // "XX" where the TIFF byte order belongs.
expect(scan.error).toMatch(/runs past the end of the file/); [
"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({});
}); });
}); });
@@ -108,10 +105,22 @@ describe("extractImageMetadata", () => {
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS), bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
); );
expect(meta?.exifError).toBeUndefined(); expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } }); expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } });
}); });
it("returns nothing for a file that is not a JPEG", () => { it("parses EXIF from a HEIC", () => {
const meta = extractImageMetadata(HEIC_WITH_EXIF);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({
Make: { value: ["Canon"] },
Model: { value: ["EOS R5"] },
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
Orientation: { value: 6 },
GPSLatitudeRef: { value: ["N"] },
});
});
it("returns nothing for a file that is not an image", () => {
const text = new TextEncoder().encode("just some text, not an image"); const text = new TextEncoder().encode("just some text, not an image");
expect(extractImageMetadata(text)).toBeUndefined(); expect(extractImageMetadata(text)).toBeUndefined();
}); });
@@ -121,14 +130,20 @@ describe("extractImageMetadata", () => {
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
); );
expect(meta?.exif).toBeUndefined(); expect(meta?.exif).toBeUndefined();
expect(meta?.exifError).toMatch(/runs past the end of the file/); expect(meta?.exifError).toBe(
"no tag could be read from the EXIF block",
);
}); });
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => { it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
const data = [...EXIF_HEADER, 0x58, 0x58]; // The raw bytes are the whole EXIF block as exifreader finds it: for a
const meta = extractImageMetadata(bytes(SOI, app1(data), SOS)); // JPEG, the APP1 segment, marker and length included.
const segment = app1([...EXIF_HEADER, 0x58, 0x58]);
const meta = extractImageMetadata(bytes(SOI, segment, SOS));
expect(meta?.exif).toBeUndefined(); expect(meta?.exif).toBeUndefined();
expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64")); expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64"));
expect(meta?.exifError).toEqual(expect.any(String)); expect(meta?.exifError).toBe(
"no tag could be read from the EXIF block",
);
}); });
}); });
+27
View File
@@ -0,0 +1,27 @@
/**
* `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same
* values as the hand-built JPEG in `library/content-library.test.ts`, for the
* tests of `exif()` and `backup-metadata --exif`.
*
* It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3),
* with libheif 1.23.0 and exiftool 13.55:
*
* apk add libheif-tools exiftool imagemagick
* magick -size 64x64 gradient:red-blue -depth 8 in.png
* heif-enc -q 30 -o exif.heic in.png
* exiftool -overwrite_original \
* -Make=Canon -Model="EOS R5" -LensModel="RF50mm F1.8 STM" \
* -DateTimeOriginal="2021:07:15 14:30:00" -OffsetTimeOriginal="+02:00" \
* -ExposureTime=1/250 -FNumber=2.8 -ISO=400 -FocalLength=50 \
* -Orientation#=6 \
* -GPSLatitude="40 26 46" -GPSLatitudeRef=N \
* -GPSLongitude="79 58 56" -GPSLongitudeRef=W \
* -GPSAltitude=12.5 -GPSAltitudeRef#=1 \
* exif.heic
*/
import { readFileSync } from "node:fs";
export const HEIC_WITH_EXIF = new Uint8Array(
readFileSync(new URL("exif.heic", import.meta.url)),
);
BIN
View File
Binary file not shown.
+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 { 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 { 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; 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 // 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([ const JPEG_WITH_BAD_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image ...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length ...[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(); 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(); const lib = await open();
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({}); expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
await lib.close(); await lib.close();
+11 -4
View File
@@ -702,6 +702,11 @@
loupe "^3.1.2" loupe "^3.1.2"
tinyrainbow "^1.2.0" tinyrainbow "^1.2.0"
"@xmldom/xmldom@^0.9.10":
version "0.9.12"
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz#1f84c07cb95ccf28202299f77b5fd7fc257151e8"
integrity sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==
acorn-jsx@^5.3.2: acorn-jsx@^5.3.2:
version "5.3.2" version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -1002,10 +1007,12 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
exif-reader@2.0.3: exifreader@4.46.0:
version "2.0.3" version "4.46.0"
resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d" resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f"
integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A== integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q==
optionalDependencies:
"@xmldom/xmldom" "^0.9.10"
expect-type@^1.1.0: expect-type@^1.1.0:
version "1.3.0" version "1.3.0"