exif(): read HEIF/HEIC originals with exifreader (closes #145) #147

Merged
clawbot merged 5 commits from issue-145-heif-exif into next 2026-10-01 22:34:09 +02:00
12 changed files with 401 additions and 199 deletions
+13 -7
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,10 +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
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take read with [exifreader](https://github.com/mattiasw/ExifReader) from any image
`--json` for machine-readable output. 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. 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 `--json` for machine-readable output.
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an fails, the error is logged, each of its files is written with the reason in an
@@ -720,8 +725,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 (such as JPEG,
gives `{}` without being downloaded. HEIC/HEIF, AVIF, PNG, WebP and TIFF), 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",
+66 -96
View File
@@ -1,60 +1,34 @@
// 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 except the
// thumbnail's; `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. // `exif` holds every tag except the thumbnail's; a tag exifreader has no name
export const extractExifFromJpeg = ( // for is keyed `undefined-<tag number>`. Each tag's `computed` holds its value
buf: Uint8Array, // as a string or number, or as an array of them for a tag with several values,
): { exif?: Buffer; error?: string } => { // such as `GPSLatitude`'s `[40, 26, 46]`. A fraction with a zero denominator
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {}; // computes to null.
let offset = 2; export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
while (offset < buf.length) { try {
if (offset + 2 > buf.length) return ExifReader.loadView(
return { error: `truncated segment marker at byte ${offset}` }; new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
if (buf[offset] !== 0xff) {
return { error: `no segment marker at byte ${offset}` }; expanded: true,
const marker = buf[offset + 1]!; computed: true,
if (marker === 0xda) return {}; // start of scan, no more markers includeOffsets: true,
if (offset + 4 > buf.length) includeUnknown: true,
return { error: `truncated segment length at byte ${offset}` }; includeTags: { exif: true, gps: true },
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) } catch {
return { return undefined;
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 +37,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;
@@ -84,56 +58,52 @@ export interface PhotoExif {
gpsAltitude?: number; gpsAltitude?: number;
} }
// exifreader gives "<faulty value>" for a tag whose value lies outside the
// file; that tag is left out like one the file lacks.
const asString = (v: unknown): string | undefined => const asString = (v: unknown): string | undefined =>
typeof v === "string" && v.length > 0 ? v : undefined; typeof v === "string" && v.length > 0 && v !== "<faulty value>"
? v
: 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 { const altitude = asNumber(exif?.GPSAltitude?.computed);
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),
gpsLongitude: asNumber(gps?.Longitude),
// A GPSAltitudeRef of 1 means the altitude is below sea level. // A GPSAltitudeRef of 1 means the altitude is below sea level.
gpsAltitude: gpsAltitude:
altitude !== undefined && gps.GPSAltitudeRef === 1 altitude !== undefined && exif?.GPSAltitudeRef?.value === 1
? -altitude ? -altitude
: altitude, : altitude,
}; };
+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", () => {
+175 -71
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 = [
@@ -22,6 +27,66 @@ const TIFF_ORIENTATION_6 = [
0x00, 0x00, 0x00, 0x00,
]; ];
// A big-endian TIFF block holding Orientation 6 and DateTimeOriginal
// "0000:00:00 00:00:00", which a camera with an unset clock writes.
const TIFF_UNSET_DATE = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: two entries, then no next IFD.
...[0x00, 0x02],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
// The Exif IFD's offset (0x8769), LONG, 38.
...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26],
...[0x00, 0x00, 0x00, 0x00],
// The Exif IFD, at 38: one entry, then no next IFD.
...[0x00, 0x01],
// DateTimeOriginal (0x9003), 20 ASCII bytes at 56.
...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38],
...[0x00, 0x00, 0x00, 0x00],
...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56
];
// A big-endian TIFF block holding a GPSAltitude of 12.5 m and no
// GPSAltitudeRef.
const TIFF_ALTITUDE_WITHOUT_REF = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// The GPS IFD's offset (0x8825), LONG, 26.
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
...[0x00, 0x00, 0x00, 0x00],
// The GPS IFD, at 26: one entry, then no next IFD.
...[0x00, 0x01],
// GPSAltitude (0x0006), one RATIONAL at 44.
...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c],
...[0x00, 0x00, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44
];
// A big-endian TIFF block holding Orientation 6 and a Make whose value lies
// past the end of the file.
const TIFF_MAKE_PAST_END = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: two entries, then no next IFD.
...[0x00, 0x02],
// Make (0x010f), 6 ASCII bytes at 4096.
...[0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x10, 0x00],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x00],
];
// A big-endian TIFF block with one IFD entry that exifreader has no name for:
// tag 0xc000 (49152), SHORT, 7.
const TIFF_UNNAMED_TAG = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// Tag 0xc000, SHORT, 7.
...[0xc0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x00],
];
// An APP1 segment whose length field matches its data. // An APP1 segment whose length field matches its data.
const app1 = (data: number[]): number[] => { const app1 = (data: number[]): number[] => {
const len = data.length + 2; const len = data.length + 2;
@@ -31,74 +96,87 @@ 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("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => {
const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE];
expect(extractExifFromJpeg(png)).toEqual({}); expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
orientation: 6,
});
}); });
it("returns nothing for a JPEG without EXIF", () => { it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => {
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF];
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({}); expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
gpsAltitude: 12.5,
});
}); });
it("ignores an APP1 segment too short to hold the Exif header", () => { it("gives no make for a Make whose value lies past the end of the file", () => {
// A length under 8 cannot hold the six-byte "Exif\0\0" header, so the const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END];
// segment is not EXIF. This one has length 7 and holds only "Exif\0", expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
// which the old code, lacking the length check, returned as EXIF. orientation: 6,
const short = app1(EXIF_HEADER.slice(0, 5)); });
expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({});
}); });
it("accepts an APP1 segment of length 8 holding just the Exif header", () => { it.each([
const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS)); [
expect(scan.error).toBeUndefined(); "a file that is not an image",
expect([...scan.exif!]).toEqual(EXIF_HEADER); new TextEncoder().encode("just some text, not an image"),
}); ],
[
it("reports a JPEG truncated inside a segment header", () => { "a PNG without EXIF",
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00])); bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
expect(scan.exif).toBeUndefined(); ],
expect(scan.error).toMatch(/truncated segment length/); ["a JPEG without EXIF", bytes(SOI, APP0, SOS)],
}); // A length under 8 cannot hold the six-byte "Exif\0\0" header. This one
// has length 7 and holds only "Exif\0", so a read past its end would
it("reports a JPEG that ends before the image data", () => { // take the next segment's bytes as EXIF.
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; [
const scan = extractExifFromJpeg(bytes(SOI, app0)); "an APP1 segment too short to hold the Exif header",
expect(scan.error).toMatch(/ends before the image data/); bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS),
}); ],
[
it("stops on a zero-length segment instead of looping", () => { "an APP1 segment holding just the Exif header",
// A length of 0 would otherwise step the scan by 2 bytes at a time bytes(SOI, app1(EXIF_HEADER), SOS),
// through the rest of the file, reading garbage as markers. ],
const zero = [0xff, 0xe0, 0x00, 0x00]; [
const scan = extractExifFromJpeg( "a JPEG truncated inside a segment header",
bytes(SOI, zero, zero, zero, zero, SOS), bytes(SOI, [0xff, 0xe1, 0x00]),
); ],
expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/); ["a JPEG that ends before the image data", bytes(SOI, APP0)],
}); // A length of 0 would step a scan by 2 bytes at a time through the
// rest of the file, reading garbage as markers.
it("stops on a segment length of 1", () => { [
const scan = extractExifFromJpeg( "a zero-length segment",
bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS), bytes(
); SOI,
expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/); ZERO_LENGTH_APP0,
}); ZERO_LENGTH_APP0,
ZERO_LENGTH_APP0,
it("reports a segment length that runs past the end of the file", () => { ZERO_LENGTH_APP0,
SOS,
),
],
["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)],
// 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 +186,30 @@ 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("keys a tag exifreader has no name for by its number", () => {
const meta = extractImageMetadata(
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_UNNAMED_TAG]), SOS),
);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({ "undefined-49152": { value: 7 } });
});
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 +219,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"