exif(): read HEIF/HEIC originals with exifreader (closes #145) #147
@@ -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,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
|
||||
`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`,
|
||||
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. 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
|
||||
`--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
|
||||
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: JPEG, HEIC/HEIF,
|
||||
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
|
||||
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+55
-99
@@ -1,60 +1,29 @@
|
||||
// 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; `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,
|
||||
),
|
||||
};
|
||||
// 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`.
|
||||
// Each tag's `computed` holds its value as a plain string or number.
|
||||
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,
|
||||
includeTags: { exif: true, gps: true },
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -63,9 +32,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;
|
||||
@@ -90,52 +59,39 @@ const asString = (v: unknown): string | 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<typeof exifReader>;
|
||||
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 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"),
|
||||
// A GPSAltitudeRef of 1 means the altitude is below sea level.
|
||||
gpsAltitude:
|
||||
altitude !== undefined && gps.GPSAltitudeRef === 1
|
||||
? -altitude
|
||||
: altitude,
|
||||
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),
|
||||
gpsAltitude: asNumber(gps?.Altitude),
|
||||
};
|
||||
// Leave out what the file lacks, so a missing field is absent rather than
|
||||
// present and undefined.
|
||||
|
||||
+4
-3
@@ -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<PhotoExif> {
|
||||
this.cacheOrThrow();
|
||||
if (this.rec.fileType === "video") return {};
|
||||
|
||||
+16
-14
@@ -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<string, unknown> | 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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = [
|
||||
@@ -31,74 +36,66 @@ 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("returns nothing for a JPEG without EXIF", () => {
|
||||
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
||||
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
|
||||
});
|
||||
|
||||
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("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 +105,22 @@ 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("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 +130,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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user