Compare commits

..
4 Commits
Author SHA1 Message Date
sneak 26bfce8b1d Date download() and backup by the album copy photo.savePath uses
check / check (push) Successful in 1m22s
Export `representative` from records.ts: the album's copy of a file that
its record is read from. `store.getFileByID` now returns that copy, so
`download()` writes under the date `photo.savePath` names. `lib.backup()`
takes each file's copy by the same rule from all of its albums, and every
album links that one path. Tests cover one file in two albums whose copies
carry different edited dates, for `download()` and for `lib.backup()`.

Model: opus-5-5
2026-10-01 20:16:39 +00:00
sneak 4b70b2889f Date a Photo's save path by the membership its record is read from
check / check (push) Successful in 1m21s
A Photo kept the first membership of its file, while its record (and so
takenAt and year) comes from the most recently synced one. The record
projection now keeps that same membership for the Photo, so savePath
and isLocal carry the date of takenAt. A test covers one file in two
albums whose edited times differ.

Model: opus-5-5
2026-10-01 19:36:36 +00:00
sneak e2d54450e8 Keep a Photo's save path after its file leaves; refuse an empty download directory
check / check (push) Successful in 1m31s
A Photo now keeps the file its record was made from (the record
projection holds one membership of each file), so savePath and isLocal
still answer after a refresh removes the file.

Library.open rejects an empty downloadDirectory.

A backup clears leftover temp files in every date folder under its
directory, not only in those of the files in its scope.

placeOriginal no longer deletes what is at the save path before copying
a live photo, and the tests that depended on that are removed.

The TODO.md entry for issue 143 describes only the current layout.

Model: opus-5-5
2026-10-01 18:45:44 +00:00
sneak 60c8d5b77b Save originals at photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; add photo.download() (closes #143)
check / check (push) Successful in 1m25s
Each original's save path is now
YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext> under the library's
download directory, dated by takenAt in the machine's time zone. The
directory defaults to photos/ in the working directory, resolved once at
open. savePath is always a string, with or without a content cache, and
isLocal is true only when the original is there.

photo.download() puts the original at its save path: copied from the
cache when the cache holds it, fetched straight there otherwise.
lib.backup() uses the same code for each file, writes each file's JSON
beside it, and links collections/ to the save paths. The backup has no
originals/ folder.

Model: opus-5-5
2026-10-01 18:17:56 +00:00
13 changed files with 210 additions and 455 deletions
+7 -13
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 an image's bytes with exifreader exif.ts EXIF read from a JPEG's bytes
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,15 +495,10 @@ 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, from it or a live photo's image, file's original through the cache and records its XMP metadata and, for a JPEG,
its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is its EXIF metadata and dimensions. `collections`, `files`, `backup`,
read with [exifreader](https://github.com/mattiasw/ExifReader) from any image `helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take
format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's `--json` for machine-readable output.
`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
@@ -745,9 +740,8 @@ Five 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. EXIF is read from any image format exifreader reads (such as JPEG, UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video
HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other gives `{}` without being downloaded.
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
@@ -36,14 +36,6 @@ declares one.
file, writes each file's JSON beside its original, and links `collections/` to file, writes each file's JSON beside its original, and links `collections/` to
the save paths. the save paths.
- 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",
"exifreader": "4.46.0", "exif-reader": "2.0.3",
"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",
+96 -66
View File
@@ -1,34 +1,60 @@
// EXIF in an original's bytes, read with exifreader, which reads it from JPEG, // EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block
// HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports. // it finds; `Photo.exif()` returns the common fields picked from it here.
// `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, { type ExpandedTags } from "exifreader"; import exifReader from "exif-reader";
// The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from // Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
// them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`). // data, starting at the "Exif\0\0" header) when there is one, nothing when the
// Undefined when exifreader cannot read the file at all, such as a video. An // bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
// EXIF block it finds but reads no tag from comes back as an empty `exif`. // malformed. Each segment length is checked against the bytes that remain and
// `exif` holds every tag except the thumbnail's; a tag exifreader has no name // each step moves forward by at least 4 bytes, so the scan ends on any input.
// for is keyed `undefined-<tag number>`. Each tag's `computed` holds its value export const extractExifFromJpeg = (
// as a string or number, or as an array of them for a tag with several values, buf: Uint8Array,
// such as `GPSLatitude`'s `[40, 26, 46]`. A fraction with a zero denominator ): { exif?: Buffer; error?: string } => {
// computes to null. if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => { let offset = 2;
try { while (offset < buf.length) {
return ExifReader.loadView( if (offset + 2 > buf.length)
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength), return { error: `truncated segment marker at byte ${offset}` };
{ if (buf[offset] !== 0xff)
expanded: true, return { error: `no segment marker at byte ${offset}` };
computed: true, const marker = buf[offset + 1]!;
includeOffsets: true, if (marker === 0xda) return {}; // start of scan, no more markers
includeUnknown: true, if (offset + 4 > buf.length)
includeTags: { exif: true, gps: true }, 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.
} catch { if (len < 2)
return undefined; return {
error: `segment length ${len} at byte ${offset} is too small`,
};
if (offset + 2 + len > buf.length)
return {
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
};
if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
// the six-byte header, so the segment is not EXIF; below 6 the
// bytes compared would also lie past the segment.
if (
len >= 8 &&
buf[offset + 4] === 0x45 &&
buf[offset + 5] === 0x78 &&
buf[offset + 6] === 0x69 &&
buf[offset + 7] === 0x66
) {
return {
exif: Buffer.from(
buf.buffer,
buf.byteOffset + offset + 4,
len - 2,
),
};
}
}
offset += 2 + len;
} }
return { error: "file ends before the image data" };
}; };
// The common EXIF fields of an original. Each is absent when the file lacks it. // The common EXIF fields of an original. Each is absent when the file lacks it.
@@ -37,9 +63,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 it is read as if it were UTC: the Date's UTC // with no time zone, and exif-reader reads that text as if it were UTC: the
// fields are the clock reading, which is the moment it was taken only when // Date's UTC fields are the clock reading, which is the moment it was taken
// the clock was set to UTC. // only when 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;
@@ -58,52 +84,56 @@ 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 !== "<faulty value>" typeof v === "string" && v.length > 0 ? v : undefined;
? 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;
// EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading const asDate = (v: unknown): Date | undefined =>
// in a Date's UTC fields. v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined;
const asDate = (v: unknown): Date | undefined => {
const m = // EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds.
typeof v === "string" // This is them as decimal degrees, negated when `negative`.
? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v) const asDegrees = (v: unknown, negative: boolean): number | undefined => {
: null; if (!Array.isArray(v) || v.length !== 3) return undefined;
if (!m) return undefined; const [d, m, s] = v.map(asNumber);
const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`); if (d === undefined || m === undefined || s === undefined) return undefined;
return Number.isNaN(date.getTime()) ? undefined : date; const degrees = d + m / 60 + s / 3600;
return negative ? -degrees : degrees;
}; };
// The common fields of an original's EXIF: `{}` when the file has no EXIF, // The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG,
// exifreader cannot read its EXIF, or it is not an image exifreader reads. // have no EXIF block, or exif-reader cannot parse it.
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => { export const readPhotoExif = (bytes: Uint8Array): PhotoExif => {
const tags = readExifTags(bytes); const { exif } = extractExifFromJpeg(bytes);
const exif = tags?.exif; if (exif === undefined) return {};
const gps = tags?.gps; let tags: ReturnType<typeof exifReader>;
const altitude = asNumber(exif?.GPSAltitude?.computed); try {
tags = exifReader(exif);
} catch {
return {};
}
const image = tags.Image ?? {};
const photo = tags.Photo ?? {};
const gps = tags.GPSInfo ?? {};
const altitude = asNumber(gps.GPSAltitude);
const fields: PhotoExif = { const fields: PhotoExif = {
make: asString(exif?.Make?.computed), make: asString(image.Make),
model: asString(exif?.Model?.computed), model: asString(image.Model),
lensModel: asString(exif?.LensModel?.computed), lensModel: asString(photo.LensModel),
dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed), dateTimeOriginal: asDate(photo.DateTimeOriginal),
offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed), offsetTimeOriginal: asString(photo.OffsetTimeOriginal),
exposureTime: asNumber(exif?.ExposureTime?.computed), exposureTime: asNumber(photo.ExposureTime),
fNumber: asNumber(exif?.FNumber?.computed), fNumber: asNumber(photo.FNumber),
// Only when the tag holds a single number, as most cameras write it. iso: asNumber(photo.ISOSpeedRatings),
iso: asNumber(exif?.ISOSpeedRatings?.computed), focalLength: asNumber(photo.FocalLength),
focalLength: asNumber(exif?.FocalLength?.computed), orientation: asNumber(image.Orientation),
orientation: asNumber(exif?.Orientation?.computed), gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"),
gpsLatitude: asNumber(gps?.Latitude), gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"),
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 && exif?.GPSAltitudeRef?.value === 1 altitude !== undefined && gps.GPSAltitudeRef === 1
? -altitude ? -altitude
: altitude, : altitude,
}; };
+10 -11
View File
@@ -117,9 +117,8 @@ export interface ContentOptions {
export interface PhotoContent { export interface PhotoContent {
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>; original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>; thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
// Put the original at the save path of `file`, the copy the `Photo` holds, // Put the original at its save path and return it there.
// and return it there. download(fileID: number): Promise<ContentResult>;
download(file: EnteFile): Promise<ContentResult>;
} }
export interface EnsureResult { export interface EnsureResult {
@@ -532,18 +531,18 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress); return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
} }
// Put the original at the save path of `file` under the download directory // Put the original at its save path under the download directory and
// and return it there. `file` is the copy the `Photo` holds, so the path is // return it there. One already stored there is returned as it is; one the
// the one its `savePath` names, even after a refresh changed the date. One // cache holds is copied from it; any other is fetched straight to the
// already stored there is returned as it is; one the cache holds is copied // save path, with no copy left in the cache.
// from it; any other is fetched straight to the save path, with no copy async download(fileID: number): Promise<ContentResult> {
// left in the cache. const file = this.getFile(fileID);
async download(file: EnteFile): Promise<ContentResult> { if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const root = this.downloadDirectory; const root = this.downloadDirectory;
const saved = const saved =
storedAtSavePath(root, file) ?? storedAtSavePath(root, file) ??
(await placeOriginal(root, file, (dest) => (await placeOriginal(root, file, (dest) =>
this.backupOriginal(file.id, dest), this.backupOriginal(fileID, dest),
)); ));
return { ...saved, bytes: fileSize(saved.path) ?? 0 }; return { ...saved, bytes: fileSize(saved.path) ?? 0 };
} }
+4 -5
View File
@@ -134,7 +134,7 @@ export class Photo {
// it, it is copied from there; otherwise it is fetched straight to // it, it is copied from there; otherwise it is fetched straight to
// `savePath`. // `savePath`.
async download(): Promise<ContentResult> { async download(): Promise<ContentResult> {
return this.cacheOrThrow().download(this.file); return this.cacheOrThrow().download(this.rec.fileID);
} }
// As `original`, for the thumbnail, through the thumbnail pool. // As `original`, for the thumbnail, through the thumbnail pool.
@@ -150,10 +150,9 @@ 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. EXIF is read from any image format exifreader reads, // may download it. Only a JPEG's EXIF is read; any other file gives `{}`,
// JPEG and HEIC/HEIF among them; any other file gives `{}`, and a video // and a video gives it without fetching anything. Like the other content
// gives it without fetching anything. Like the other content methods, it // methods, it throws when there is no content cache, video or not.
// 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 {};
+14 -16
View File
@@ -1,8 +1,9 @@
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 { readExifTags } from "./exif.js"; import { extractExifFromJpeg } 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 {
@@ -19,10 +20,9 @@ export interface MetadataBackupOptions {
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
} }
// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags // Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment
// exifreader returns, from any image format it reads. When it finds an EXIF // is malformed or cannot be parsed, the record carries the reason in
// block but reads no tag from it, the record keeps the block's bytes, base64, // `exifError`.
// 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,21 +39,19 @@ 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; unreadable EXIF is still // is expected and only means no dimensions; a malformed JPEG is still
// reported below through `exifError`. // reported below through `exifError`.
} }
const tags = readExifTags(fileBytes); const { exif, error } = extractExifFromJpeg(fileBytes);
if (tags?.exif && Object.keys(tags.exif).length > 0) { if (error) result.exifError = error;
result.exif = tags.exif; if (exif) {
} else if (tags?.exif) { try {
const block = tags.metadataRange?.blocks.find((b) => b.type === "exif"); result.exif = exifReader(exif);
if (block) { } catch (err) {
result.exifRaw = Buffer.from( result.exifRaw = exif.toString("base64");
fileBytes.subarray(block.start, block.end), result.exifError = err instanceof Error ? err.message : String(err);
).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,7 +56,6 @@ 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,
@@ -654,31 +653,6 @@ 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", () => {
+71 -175
View File
@@ -1,24 +1,19 @@
/** /**
* Tests for reading EXIF (`src/exif.ts`) and the image metadata * Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
* `quak backup-metadata --exif` records.
* *
* The originals come from users' libraries, so a truncated or corrupt file * The originals come from users' libraries, so a truncated or corrupt JPEG
* must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`, * must neither hang the scan nor throw out of it, and a malformed file must be
* and `backup-metadata` tells an EXIF block it cannot read apart from a file * told apart from one that simply has no EXIF: the record carries the reason in
* that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below * `exifError`. Each input below is a short hand-built byte array.
* 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 { readPhotoExif } from "../../src/exif.js"; import { extractExifFromJpeg } 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 const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
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 = [
@@ -27,66 +22,6 @@ 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;
@@ -96,87 +31,74 @@ const app1 = (data: number[]): number[] => {
const bytes = (...parts: number[][]): Uint8Array => const bytes = (...parts: number[][]): Uint8Array =>
new Uint8Array(parts.flat()); new Uint8Array(parts.flat());
describe("readPhotoExif", () => { describe("extractExifFromJpeg", () => {
it("reads the common fields of a valid JPEG", () => { it("returns the EXIF segment of a valid JPEG", () => {
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6]; const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS));
orientation: 6, expect(scan.error).toBeUndefined();
}); expect([...scan.exif!]).toEqual(data);
}); });
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => { it("returns nothing for a file that is not a JPEG", () => {
const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE]; const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ expect(extractExifFromJpeg(png)).toEqual({});
orientation: 6,
});
}); });
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => { it("returns nothing for a JPEG without EXIF", () => {
const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF]; const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
gpsAltitude: 12.5,
});
}); });
it("gives no make for a Make whose value lies past the end of the file", () => { it("ignores an APP1 segment too short to hold the Exif header", () => {
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END]; // A length under 8 cannot hold the six-byte "Exif\0\0" header, so the
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({ // segment is not EXIF. This one has length 7 and holds only "Exif\0",
orientation: 6, // 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.each([ it("accepts an APP1 segment of length 8 holding just the Exif header", () => {
[ const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS));
"a file that is not an image", expect(scan.error).toBeUndefined();
new TextEncoder().encode("just some text, not an image"), expect([...scan.exif!]).toEqual(EXIF_HEADER);
], });
[
"a PNG without EXIF", it("reports a JPEG truncated inside a segment header", () => {
bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
], expect(scan.exif).toBeUndefined();
["a JPEG without EXIF", bytes(SOI, APP0, SOS)], expect(scan.error).toMatch(/truncated segment length/);
// 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. it("reports a JPEG that ends before the image data", () => {
[ const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
"an APP1 segment too short to hold the Exif header", const scan = extractExifFromJpeg(bytes(SOI, app0));
bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS), expect(scan.error).toMatch(/ends before the image data/);
], });
[
"an APP1 segment holding just the Exif header", it("stops on a zero-length segment instead of looping", () => {
bytes(SOI, app1(EXIF_HEADER), SOS), // A length of 0 would otherwise step the scan by 2 bytes at a time
], // through the rest of the file, reading garbage as markers.
[ const zero = [0xff, 0xe0, 0x00, 0x00];
"a JPEG truncated inside a segment header", const scan = extractExifFromJpeg(
bytes(SOI, [0xff, 0xe1, 0x00]), bytes(SOI, zero, zero, zero, zero, SOS),
], );
["a JPEG that ends before the image data", bytes(SOI, APP0)], expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
// 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", () => {
"a zero-length segment", const scan = extractExifFromJpeg(
bytes( bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
SOI, );
ZERO_LENGTH_APP0, 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", () => {
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),
], );
// "XX" where the TIFF byte order belongs. expect(scan.exif).toBeUndefined();
[ 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({});
}); });
}); });
@@ -186,30 +108,10 @@ 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({ Orientation: { value: 6 } }); expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } });
}); });
it("parses EXIF from a HEIC", () => { it("returns nothing for a file that is not a JPEG", () => {
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();
}); });
@@ -219,20 +121,14 @@ 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).toBe( expect(meta?.exifError).toMatch(/runs past the end of the file/);
"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", () => {
// The raw bytes are the whole EXIF block as exifreader finds it: for a const data = [...EXIF_HEADER, 0x58, 0x58];
// JPEG, the APP1 segment, marker and length included. const meta = extractImageMetadata(bytes(SOI, app1(data), SOS));
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(segment).toString("base64")); expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64"));
expect(meta?.exifError).toBe( expect(meta?.exifError).toEqual(expect.any(String));
"no tag could be read from the EXIF block",
);
}); });
}); });
-27
View File
@@ -1,27 +0,0 @@
/**
* `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.
+3 -96
View File
@@ -27,16 +27,7 @@ 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 type { PhotoExif } from "../../src/exif.js"; import { asLivePhoto, cdnSource, IMAGE, livePhotoZip } from "../live-photo.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;
@@ -317,7 +308,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 exifreader cannot parse it. // byte order belongs, so exif-reader 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
@@ -466,48 +457,6 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
it("downloads a photo held across a refresh that edits its date to the save path it names", async () => {
// The same account, whose second refresh brings a date edited in Ente.
const edited = file(1, 1);
edited.updationTime = 2;
edited.pubMagicMetadata = {
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
};
class DateEditedClient extends MockClient {
refreshes = 0;
override async collectionsSince(): Promise<CollectionsPage> {
this.refreshes++;
return {
collections: [
{ ...collection(1), updationTime: this.refreshes },
],
deleted: [],
cursor: this.refreshes,
};
}
override async filesSince(): Promise<FilesPage> {
return {
files: [this.refreshes === 1 ? file(1, 1) : edited],
deleted: [],
cursor: this.refreshes,
};
}
}
const lib = await open({ client: new DateEditedClient() });
const photo = lib.photos.byID({ fileID: 1 })!;
await lib.fresh();
expect(lib.photos.byID({ fileID: 1 })!.takenAt).toBe(
new Date(2026, 3, 15, 12).getTime(),
);
const saved = await photo.download();
expect(saved.path).toBe(photo.savePath);
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
expect(photo.isLocal).toBe(true);
await lib.close();
});
it("has a save path, and is not local, without a content source", async () => { it("has a save path, and is not local, without a content source", async () => {
const lib = await open({ contentSource: undefined }); const lib = await open({ contentSource: undefined });
const photo = lib.photos.byID({ fileID: 1 })!; const photo = lib.photos.byID({ fileID: 1 })!;
@@ -644,49 +593,7 @@ describe("Photo save path, local copy, content and EXIF", () => {
await lib.close(); await lib.close();
}); });
// What exif() returns for HEIC_WITH_EXIF, which holds the same values as it("returns no EXIF fields for an original that is not a JPEG", async () => {
// 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();
+4 -11
View File
@@ -702,11 +702,6 @@
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"
@@ -1007,12 +1002,10 @@ 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==
exifreader@4.46.0: exif-reader@2.0.3:
version "4.46.0" version "2.0.3"
resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f" resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d"
integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q== integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==
optionalDependencies:
"@xmldom/xmldom" "^0.9.10"
expect-type@^1.1.0: expect-type@^1.1.0:
version "1.3.0" version "1.3.0"