Compare commits

..
6 Commits
Author SHA1 Message Date
sneak fc06b9100b Have photo.download() write to the save path of the Photo's own file copy
check / check (push) Successful in 4m9s
Photo.download() now hands the content cache the file copy the Photo
holds, the one savePath and isLocal are dated from, instead of the
fileID the cache then looked up in the store. A Photo held across a
refresh that brings a date edited in Ente is now downloaded to the path
its savePath names. A test covers that case.

Model: opus-5-5
2026-10-01 21:01:33 +00:00
sneak 8992c7c5a9 Date download() and backup by the album copy photo.savePath uses
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:56:39 +00:00
sneak 2323da4727 Date a Photo's save path by the membership its record is read from
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 20:56:39 +00:00
sneak 4c72e05002 Keep a Photo's save path after its file leaves; refuse an empty download directory
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 20:56:39 +00:00
sneak bab19660f5 Save originals at photos/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.fileID.ext; add photo.download() (closes #143)
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 20:56:39 +00:00
clawbot 67d554fb46 exif(): read HEIF/HEIC originals with exifreader (closes #145)
check / check (push) Successful in 2m10s
`photo.exif()` and `quak backup-metadata --exif` now read EXIF through `exifreader`. HEIC/HEIF originals get EXIF, including a live photo's image, as do the other formats `exifreader` reads. It replaces `exif-reader` and the hand-written JPEG scan. `PhotoExif` is unchanged.

The `backup-metadata` dump now holds `exifreader`'s tag output, with unnamed tags keyed `undefined-` plus their number. GPS altitude without a reference counts as above sea level. A latitude or longitude without its hemisphere tag, an unreadable text tag, and a date the parser rejects each give no field.

Licence: `exifreader` is MPL-2.0, used unmodified.

Model: opus-5-5
2026-10-01 22:34:09 +02:00
13 changed files with 455 additions and 210 deletions
+13 -7
View File
@@ -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
@@ -740,8 +745,9 @@ Five 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
+8
View File
@@ -36,6 +36,14 @@ declares one.
file, writes each file's JSON beside its original, and links `collections/` to
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()`,
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
`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",
"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",
+66 -96
View File
@@ -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-<tag number>`. 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 "<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 =>
typeof v === "string" && v.length > 0 ? v : undefined;
typeof v === "string" && v.length > 0 && v !== "<faulty value>"
? v
: undefined;
const asNumber = (v: unknown): number | undefined =>
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 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,
};
+11 -10
View File
@@ -117,8 +117,9 @@ export interface ContentOptions {
export interface PhotoContent {
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
// Put the original at its save path and return it there.
download(fileID: number): Promise<ContentResult>;
// Put the original at the save path of `file`, the copy the `Photo` holds,
// and return it there.
download(file: EnteFile): Promise<ContentResult>;
}
export interface EnsureResult {
@@ -531,18 +532,18 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
}
// Put the original at its save path under the download directory and
// return it there. One already stored there is returned as it is; one the
// cache holds is copied from it; any other is fetched straight to the
// save path, with no copy left in the cache.
async download(fileID: number): Promise<ContentResult> {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
// Put the original at the save path of `file` under the download directory
// and return it there. `file` is the copy the `Photo` holds, so the path is
// the one its `savePath` names, even after a refresh changed the date. One
// already stored there is returned as it is; one the cache holds is copied
// from it; any other is fetched straight to the save path, with no copy
// left in the cache.
async download(file: EnteFile): Promise<ContentResult> {
const root = this.downloadDirectory;
const saved =
storedAtSavePath(root, file) ??
(await placeOriginal(root, file, (dest) =>
this.backupOriginal(fileID, dest),
this.backupOriginal(file.id, dest),
));
return { ...saved, bytes: fileSize(saved.path) ?? 0 };
}
+5 -4
View File
@@ -134,7 +134,7 @@ export class Photo {
// it, it is copied from there; otherwise it is fetched straight to
// `savePath`.
async download(): Promise<ContentResult> {
return this.cacheOrThrow().download(this.rec.fileID);
return this.cacheOrThrow().download(this.file);
}
// As `original`, for the thumbnail, through the thumbnail pool.
@@ -150,9 +150,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
View File
@@ -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)
+26
View File
@@ -56,6 +56,7 @@ import type { ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import { 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", () => {
+175 -71
View File
@@ -1,19 +1,24 @@
/**
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
* Tests for reading EXIF (`src/exif.ts`) and the image metadata
* `quak backup-metadata --exif` records.
*
* The originals come from users' libraries, so a truncated or corrupt JPEG
* 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",
);
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same
* values as the hand-built JPEG in `library/content-library.test.ts`, for the
* tests of `exif()` and `backup-metadata --exif`.
*
* It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3),
* with libheif 1.23.0 and exiftool 13.55:
*
* apk add libheif-tools exiftool imagemagick
* magick -size 64x64 gradient:red-blue -depth 8 in.png
* heif-enc -q 30 -o exif.heic in.png
* exiftool -overwrite_original \
* -Make=Canon -Model="EOS R5" -LensModel="RF50mm F1.8 STM" \
* -DateTimeOriginal="2021:07:15 14:30:00" -OffsetTimeOriginal="+02:00" \
* -ExposureTime=1/250 -FNumber=2.8 -ISO=400 -FocalLength=50 \
* -Orientation#=6 \
* -GPSLatitude="40 26 46" -GPSLatitudeRef=N \
* -GPSLongitude="79 58 56" -GPSLongitudeRef=W \
* -GPSAltitude=12.5 -GPSAltitudeRef#=1 \
* exif.heic
*/
import { readFileSync } from "node:fs";
export const HEIC_WITH_EXIF = new Uint8Array(
readFileSync(new URL("exif.heic", import.meta.url)),
);
BIN
View File
Binary file not shown.
+96 -3
View File
@@ -27,7 +27,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;
@@ -308,7 +317,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
@@ -457,6 +466,48 @@ describe("Photo save path, local copy, content and EXIF", () => {
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 () => {
const lib = await open({ contentSource: undefined });
const photo = lib.photos.byID({ fileID: 1 })!;
@@ -593,7 +644,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();
+11 -4
View File
@@ -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"