diff --git a/README.md b/README.md index a75f15b..80c573d 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ quak/ backup.ts resilient full-account backup with dedup metadata-backup.ts 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 filename.ts safe file names from server metadata 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 `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 -file's original through the cache and records its XMP metadata and, for a JPEG, -its EXIF metadata and dimensions. `collections`, `files`, `backup`, -`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take -`--json` for machine-readable output. +file's original through the cache and records, from it or a live photo's image, +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. 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 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 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. Only a JPEG's EXIF is read: any other original gives `{}`, and a video - gives `{}` without being downloaded. + UTC. EXIF is read from any image format exifreader reads (such as JPEG, + 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 otherwise fetch through the pools; `original()`, `content()` and `exif()` also diff --git a/TODO.md b/TODO.md index 4c188b5..b342066 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ declares one. # 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()`, `modifiedAt`, `hash` and `year` (issue 141). `savePath` is where `lib.backup()` writes the original under the library's download directory; for diff --git a/package.json b/package.json index f1a1d6f..779a239 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@inquirer/prompts": "8.5.2", "commander": "14.0.3", "env-paths": "4.0.0", - "exif-reader": "2.0.3", + "exifreader": "4.46.0", "fast-srp-hap": "2.0.4", "fflate": "0.8.3", "jpeg-js": "0.4.4", diff --git a/src/exif.ts b/src/exif.ts index 778cbfa..b0f13f5 100644 --- a/src/exif.ts +++ b/src/exif.ts @@ -1,60 +1,34 @@ -// EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block -// it finds; `Photo.exif()` returns the common fields picked from it here. +// EXIF in an original's bytes, read with exifreader, which reads it from JPEG, +// 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 -// data, starting at the "Exif\0\0" header) when there is one, nothing when the -// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is -// malformed. Each segment length is checked against the bytes that remain and -// each step moves forward by at least 4 bytes, so the scan ends on any input. -export const extractExifFromJpeg = ( - buf: Uint8Array, -): { exif?: Buffer; error?: string } => { - if (buf[0] !== 0xff || buf[1] !== 0xd8) return {}; - let offset = 2; - while (offset < buf.length) { - if (offset + 2 > buf.length) - return { error: `truncated segment marker at byte ${offset}` }; - if (buf[offset] !== 0xff) - return { error: `no segment marker at byte ${offset}` }; - const marker = buf[offset + 1]!; - if (marker === 0xda) return {}; // start of scan, no more markers - if (offset + 4 > buf.length) - 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; +// The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from +// them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`). +// Undefined when exifreader cannot read the file at all, such as a video. An +// EXIF block it finds but reads no tag from comes back as an empty `exif`. +// `exif` holds every tag except the thumbnail's; a tag exifreader has no name +// for is keyed `undefined-`. Each tag's `computed` holds its value +// as a string or number, or as an array of them for a tag with several values, +// such as `GPSLatitude`'s `[40, 26, 46]`. A fraction with a zero denominator +// computes to null. +export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { + try { + return ExifReader.loadView( + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), + { + expanded: true, + computed: true, + includeOffsets: true, + includeUnknown: true, + includeTags: { exif: true, gps: true }, + }, + ); + } catch { + return undefined; } - return { error: "file ends before the image data" }; }; // The common EXIF fields of an original. Each is absent when the file lacks it. @@ -63,9 +37,9 @@ export interface PhotoExif { model?: string; lensModel?: string; // 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 - // Date's UTC fields are the clock reading, which is the moment it was taken - // only when the clock was set to UTC. + // with no time zone, and it is read as if it were UTC: the Date's UTC + // fields are the clock reading, which is the moment it was taken only when + // the clock was set to UTC. dateTimeOriginal?: Date; // The camera clock's offset from UTC, such as "+02:00". offsetTimeOriginal?: string; @@ -84,56 +58,52 @@ export interface PhotoExif { gpsAltitude?: number; } +// exifreader gives "" 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 => - typeof v === "string" && v.length > 0 ? v : undefined; + typeof v === "string" && v.length > 0 && v !== "" + ? v + : undefined; const asNumber = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : undefined; -const asDate = (v: unknown): Date | undefined => - v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined; - -// EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds. -// This is them as decimal degrees, negated when `negative`. -const asDegrees = (v: unknown, negative: boolean): number | undefined => { - if (!Array.isArray(v) || v.length !== 3) return undefined; - const [d, m, s] = v.map(asNumber); - if (d === undefined || m === undefined || s === undefined) return undefined; - const degrees = d + m / 60 + s / 3600; - return negative ? -degrees : degrees; +// EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading +// in a Date's UTC fields. +const asDate = (v: unknown): Date | undefined => { + const m = + typeof v === "string" + ? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v) + : null; + if (!m) return undefined; + const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`); + return Number.isNaN(date.getTime()) ? undefined : date; }; -// The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG, -// have no EXIF block, or exif-reader cannot parse it. +// The common fields of an original's EXIF: `{}` when the file has no EXIF, +// exifreader cannot read its EXIF, or it is not an image exifreader reads. export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { - const { exif } = extractExifFromJpeg(bytes); - if (exif === undefined) return {}; - let tags: ReturnType; - try { - tags = exifReader(exif); - } catch { - return {}; - } - const image = tags.Image ?? {}; - const photo = tags.Photo ?? {}; - const gps = tags.GPSInfo ?? {}; - const altitude = asNumber(gps.GPSAltitude); + const tags = readExifTags(bytes); + const exif = tags?.exif; + const gps = tags?.gps; + const altitude = asNumber(exif?.GPSAltitude?.computed); const fields: PhotoExif = { - make: asString(image.Make), - model: asString(image.Model), - lensModel: asString(photo.LensModel), - dateTimeOriginal: asDate(photo.DateTimeOriginal), - offsetTimeOriginal: asString(photo.OffsetTimeOriginal), - exposureTime: asNumber(photo.ExposureTime), - fNumber: asNumber(photo.FNumber), - iso: asNumber(photo.ISOSpeedRatings), - focalLength: asNumber(photo.FocalLength), - orientation: asNumber(image.Orientation), - gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"), - gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"), + make: asString(exif?.Make?.computed), + model: asString(exif?.Model?.computed), + lensModel: asString(exif?.LensModel?.computed), + dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed), + offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed), + exposureTime: asNumber(exif?.ExposureTime?.computed), + fNumber: asNumber(exif?.FNumber?.computed), + // Only when the tag holds a single number, as most cameras write it. + iso: asNumber(exif?.ISOSpeedRatings?.computed), + focalLength: asNumber(exif?.FocalLength?.computed), + orientation: asNumber(exif?.Orientation?.computed), + gpsLatitude: asNumber(gps?.Latitude), + gpsLongitude: asNumber(gps?.Longitude), // A GPSAltitudeRef of 1 means the altitude is below sea level. gpsAltitude: - altitude !== undefined && gps.GPSAltitudeRef === 1 + altitude !== undefined && exif?.GPSAltitudeRef?.value === 1 ? -altitude : altitude, }; diff --git a/src/library/read.ts b/src/library/read.ts index a10731d..68a8dd2 100644 --- a/src/library/read.ts +++ b/src/library/read.ts @@ -130,9 +130,10 @@ export class Photo { } // 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 `{}`, - // and a video gives it without fetching anything. Like the other content - // methods, it throws when there is no content cache, video or not. + // may download it. EXIF is read from any image format exifreader reads, + // JPEG and HEIC/HEIF among them; any other file gives `{}`, and a video + // 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 { this.cacheOrThrow(); if (this.rec.fileType === "video") return {}; diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 420fb9e..d453bf8 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -1,9 +1,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import * as jpeg from "jpeg-js"; -import exifReader from "exif-reader"; 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 { sanitizeFileName } from "./filename.js"; import { @@ -20,9 +19,10 @@ export interface MetadataBackupOptions { onProgress?: ProgressCallback; } -// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment -// is malformed or cannot be parsed, the record carries the reason in -// `exifError`. +// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags +// exifreader returns, from any image format it reads. When it finds an EXIF +// 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 = ( fileBytes: Uint8Array, ): Record | undefined => { @@ -39,19 +39,21 @@ export const extractImageMetadata = ( result.height = decoded.height; } catch { // 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`. } - const { exif, error } = extractExifFromJpeg(fileBytes); - if (error) result.exifError = error; - if (exif) { - try { - result.exif = exifReader(exif); - } catch (err) { - result.exifRaw = exif.toString("base64"); - result.exifError = err instanceof Error ? err.message : String(err); + const tags = readExifTags(fileBytes); + if (tags?.exif && Object.keys(tags.exif).length > 0) { + result.exif = tags.exif; + } else if (tags?.exif) { + const block = tags.metadataRange?.blocks.find((b) => b.type === "exif"); + if (block) { + result.exifRaw = Buffer.from( + 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) diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index 5b441e5..ff939ff 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -56,6 +56,7 @@ import type { ContentSource } from "../../src/library/content.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { init, toBase64 } from "../../src/crypto/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { asLivePhoto, cdnSource, @@ -653,6 +654,31 @@ describe("a live photo", () => { 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", () => { diff --git a/test/cli/metadata-exif.test.ts b/test/cli/metadata-exif.test.ts index 870826c..273181c 100644 --- a/test/cli/metadata-exif.test.ts +++ b/test/cli/metadata-exif.test.ts @@ -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 - * must neither hang the scan nor throw out of it, and a malformed file must be - * told apart from one that simply has no EXIF: the record carries the reason in - * `exifError`. Each input below is a short hand-built byte array. + * The originals come from users' libraries, so a truncated or corrupt file + * must neither hang the read nor throw out of it: `readPhotoExif` 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 { extractExifFromJpeg } from "../../src/exif.js"; +import { readPhotoExif } from "../../src/exif.js"; import { extractImageMetadata } from "../../src/metadata-backup.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; 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 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. const TIFF_ORIENTATION_6 = [ @@ -22,6 +27,66 @@ const TIFF_ORIENTATION_6 = [ 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. const app1 = (data: number[]): number[] => { const len = data.length + 2; @@ -31,74 +96,87 @@ const app1 = (data: number[]): number[] => { const bytes = (...parts: number[][]): Uint8Array => new Uint8Array(parts.flat()); -describe("extractExifFromJpeg", () => { - it("returns the EXIF segment of a valid JPEG", () => { +describe("readPhotoExif", () => { + it("reads the common fields of a valid JPEG", () => { const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6]; - const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS)); - expect(scan.error).toBeUndefined(); - expect([...scan.exif!]).toEqual(data); + expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ + orientation: 6, + }); }); - it("returns nothing for a file that is not a JPEG", () => { - const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - expect(extractExifFromJpeg(png)).toEqual({}); + 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({ + orientation: 6, + }); }); - it("returns nothing for a JPEG without EXIF", () => { - const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; - expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({}); + 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({ + gpsAltitude: 12.5, + }); }); - 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, so the - // segment is not EXIF. This one has length 7 and holds only "Exif\0", - // which the old code, lacking the length check, returned as EXIF. - const short = app1(EXIF_HEADER.slice(0, 5)); - expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({}); + 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({ + orientation: 6, + }); }); - it("accepts an APP1 segment of length 8 holding just the Exif header", () => { - const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS)); - expect(scan.error).toBeUndefined(); - expect([...scan.exif!]).toEqual(EXIF_HEADER); - }); - - it("reports a JPEG truncated inside a segment header", () => { - const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00])); - expect(scan.exif).toBeUndefined(); - expect(scan.error).toMatch(/truncated segment length/); - }); - - it("reports a JPEG that ends before the image data", () => { - const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00]; - const scan = extractExifFromJpeg(bytes(SOI, app0)); - expect(scan.error).toMatch(/ends before the image data/); - }); - - it("stops on a zero-length segment instead of looping", () => { - // 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]; - 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", () => { + it.each([ + [ + "a file that is not an image", + new TextEncoder().encode("just some text, not an image"), + ], + [ + "a PNG without EXIF", + bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ], + ["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 + // take the next segment's bytes as EXIF. + [ + "an APP1 segment too short to hold the Exif header", + bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS), + ], + [ + "an APP1 segment holding just the Exif header", + bytes(SOI, app1(EXIF_HEADER), SOS), + ], + [ + "a JPEG truncated inside a segment header", + bytes(SOI, [0xff, 0xe1, 0x00]), + ], + ["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. + [ + "a zero-length segment", + bytes( + SOI, + ZERO_LENGTH_APP0, + ZERO_LENGTH_APP0, + ZERO_LENGTH_APP0, + 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. - const scan = extractExifFromJpeg( + [ + "a segment length that runs past the end of the file", bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), - ); - expect(scan.exif).toBeUndefined(); - expect(scan.error).toMatch(/runs past the end of the file/); + ], + // "XX" where the TIFF byte order belongs. + [ + "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), ); 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"); expect(extractImageMetadata(text)).toBeUndefined(); }); @@ -121,14 +219,20 @@ describe("extractImageMetadata", () => { bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), ); 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", () => { - const data = [...EXIF_HEADER, 0x58, 0x58]; - const meta = extractImageMetadata(bytes(SOI, app1(data), SOS)); + // The raw bytes are the whole EXIF block as exifreader finds it: for a + // 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?.exifRaw).toBe(Buffer.from(data).toString("base64")); - expect(meta?.exifError).toEqual(expect.any(String)); + expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64")); + expect(meta?.exifError).toBe( + "no tag could be read from the EXIF block", + ); }); }); diff --git a/test/exif-heic.ts b/test/exif-heic.ts new file mode 100644 index 0000000..ad3acc6 --- /dev/null +++ b/test/exif-heic.ts @@ -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)), +); diff --git a/test/exif.heic b/test/exif.heic new file mode 100644 index 0000000..b6b460f Binary files /dev/null and b/test/exif.heic differ diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index debd23c..7e5ad27 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -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(); diff --git a/yarn.lock b/yarn.lock index 7759c2d..4386074 100644 --- a/yarn.lock +++ b/yarn.lock @@ -702,6 +702,11 @@ loupe "^3.1.2" 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: version "5.3.2" 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" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -exif-reader@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d" - integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A== +exifreader@4.46.0: + version "4.46.0" + resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f" + integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q== + optionalDependencies: + "@xmldom/xmldom" "^0.9.10" expect-type@^1.1.0: version "1.3.0"