exif(): read HEIF/HEIC originals with exifreader (closes #145) #147
@@ -224,7 +224,7 @@ quak/
|
|||||||
backup.ts resilient full-account backup with dedup
|
backup.ts resilient full-account backup with dedup
|
||||||
metadata-backup.ts
|
metadata-backup.ts
|
||||||
backup-metadata: the metadata quak keeps, as JSON
|
backup-metadata: the metadata quak keeps, as JSON
|
||||||
exif.ts EXIF read from a JPEG's bytes
|
exif.ts EXIF read from an image's bytes with exifreader
|
||||||
mldata-fetch.ts fetch + decrypt per-file ML data
|
mldata-fetch.ts fetch + decrypt per-file ML data
|
||||||
filename.ts safe file names from server metadata
|
filename.ts safe file names from server metadata
|
||||||
errors.ts error types shared across layers
|
errors.ts error types shared across layers
|
||||||
@@ -495,10 +495,15 @@ Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
|
|||||||
`--out PATH`, the image is written to `PATH` and the video beside it, with
|
`--out PATH`, the image is written to `PATH` and the video beside it, with
|
||||||
`PATH`'s name and the video's extension; a `PATH` with the video's extension is
|
`PATH`'s name and the video's extension; a `PATH` with the video's extension is
|
||||||
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
|
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
|
||||||
file's original through the cache and records its XMP metadata and, for a JPEG,
|
file's original through the cache and records, from it or a live photo's image,
|
||||||
its EXIF metadata and dimensions. `collections`, `files`, `backup`,
|
its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is
|
||||||
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` take
|
read with [exifreader](https://github.com/mattiasw/ExifReader) from any image
|
||||||
`--json` for machine-readable output.
|
format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's
|
||||||
|
`exif` field is exifreader's EXIF tag output: each tag by name, with its
|
||||||
|
`value`, `description` and `computed` value. An EXIF block exifreader finds but
|
||||||
|
reads no tag from is recorded, base64, as `exifRaw`, with the reason in
|
||||||
|
`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails`
|
||||||
|
and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
|
||||||
|
|
||||||
`backup-metadata` fetches ML data in requests of up to 200 files. When a request
|
`backup-metadata` fetches ML data in requests of up to 200 files. When a request
|
||||||
fails, the error is logged, each of its files is written with the reason in an
|
fails, the error is logged, each of its files is written with the reason in an
|
||||||
@@ -720,8 +725,9 @@ Four async methods may download:
|
|||||||
each absent when the file lacks it. GPS values are signed decimal degrees and
|
each absent when the file lacks it. GPS values are signed decimal degrees and
|
||||||
metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s
|
metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s
|
||||||
UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from
|
UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from
|
||||||
UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video
|
UTC. EXIF is read from any image format exifreader reads (such as JPEG,
|
||||||
gives `{}` without being downloaded.
|
HEIC/HEIF, AVIF, PNG, WebP and TIFF), a live photo's image included. Any other
|
||||||
|
original gives `{}`, and a video gives `{}` without being downloaded.
|
||||||
|
|
||||||
They serve from the on-disk content cache when the bytes are present and
|
They serve from the on-disk content cache when the bytes are present and
|
||||||
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
|
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ declares one.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from
|
||||||
|
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
|
||||||
|
the other image formats `exifreader` reads (issue 145). `exifreader` replaces
|
||||||
|
`exif-reader` and the JPEG segment scan; `PhotoExif` is unchanged. The `exif`
|
||||||
|
field of `backup-metadata --exif` is now exifreader's tag output, and
|
||||||
|
`exifRaw` holds the whole EXIF block it could not read. The tests use a real
|
||||||
|
HEIC, `test/exif.heic`.
|
||||||
|
|
||||||
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
|
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
|
||||||
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
|
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
|
||||||
`lib.backup()` writes the original under the library's download directory; for
|
`lib.backup()` writes the original under the library's download directory; for
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@
|
|||||||
"@inquirer/prompts": "8.5.2",
|
"@inquirer/prompts": "8.5.2",
|
||||||
"commander": "14.0.3",
|
"commander": "14.0.3",
|
||||||
"env-paths": "4.0.0",
|
"env-paths": "4.0.0",
|
||||||
"exif-reader": "2.0.3",
|
"exifreader": "4.46.0",
|
||||||
"fast-srp-hap": "2.0.4",
|
"fast-srp-hap": "2.0.4",
|
||||||
"fflate": "0.8.3",
|
"fflate": "0.8.3",
|
||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
|
|||||||
+66
-96
@@ -1,60 +1,34 @@
|
|||||||
// EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block
|
// EXIF in an original's bytes, read with exifreader, which reads it from JPEG,
|
||||||
// it finds; `Photo.exif()` returns the common fields picked from it here.
|
// HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports.
|
||||||
|
// `backup-metadata --exif` records every EXIF tag it finds except the
|
||||||
|
// thumbnail's; `Photo.exif()` returns the common fields picked from them here.
|
||||||
|
|
||||||
import exifReader from "exif-reader";
|
import ExifReader, { type ExpandedTags } from "exifreader";
|
||||||
|
|
||||||
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
|
// The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from
|
||||||
// data, starting at the "Exif\0\0" header) when there is one, nothing when the
|
// them (`gps`), and where the EXIF block lies in `bytes` (`metadataRange`).
|
||||||
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
|
// Undefined when exifreader cannot read the file at all, such as a video. An
|
||||||
// malformed. Each segment length is checked against the bytes that remain and
|
// EXIF block it finds but reads no tag from comes back as an empty `exif`.
|
||||||
// each step moves forward by at least 4 bytes, so the scan ends on any input.
|
// `exif` holds every tag except the thumbnail's; a tag exifreader has no name
|
||||||
export const extractExifFromJpeg = (
|
// for is keyed `undefined-<tag number>`. Each tag's `computed` holds its value
|
||||||
buf: Uint8Array,
|
// as a string or number, or as an array of them for a tag with several values,
|
||||||
): { exif?: Buffer; error?: string } => {
|
// such as `GPSLatitude`'s `[40, 26, 46]`. A fraction with a zero denominator
|
||||||
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
|
// computes to null.
|
||||||
let offset = 2;
|
export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
|
||||||
while (offset < buf.length) {
|
try {
|
||||||
if (offset + 2 > buf.length)
|
return ExifReader.loadView(
|
||||||
return { error: `truncated segment marker at byte ${offset}` };
|
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
|
||||||
if (buf[offset] !== 0xff)
|
{
|
||||||
return { error: `no segment marker at byte ${offset}` };
|
expanded: true,
|
||||||
const marker = buf[offset + 1]!;
|
computed: true,
|
||||||
if (marker === 0xda) return {}; // start of scan, no more markers
|
includeOffsets: true,
|
||||||
if (offset + 4 > buf.length)
|
includeUnknown: true,
|
||||||
return { error: `truncated segment length at byte ${offset}` };
|
includeTags: { exif: true, gps: true },
|
||||||
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
|
},
|
||||||
// The length counts its own two bytes, so anything under 2 is invalid.
|
);
|
||||||
if (len < 2)
|
} catch {
|
||||||
return {
|
return undefined;
|
||||||
error: `segment length ${len} at byte ${offset} is too small`,
|
|
||||||
};
|
|
||||||
if (offset + 2 + len > buf.length)
|
|
||||||
return {
|
|
||||||
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
|
|
||||||
};
|
|
||||||
if (marker === 0xe1) {
|
|
||||||
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
|
|
||||||
// the six-byte header, so the segment is not EXIF; below 6 the
|
|
||||||
// bytes compared would also lie past the segment.
|
|
||||||
if (
|
|
||||||
len >= 8 &&
|
|
||||||
buf[offset + 4] === 0x45 &&
|
|
||||||
buf[offset + 5] === 0x78 &&
|
|
||||||
buf[offset + 6] === 0x69 &&
|
|
||||||
buf[offset + 7] === 0x66
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
exif: Buffer.from(
|
|
||||||
buf.buffer,
|
|
||||||
buf.byteOffset + offset + 4,
|
|
||||||
len - 2,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
offset += 2 + len;
|
|
||||||
}
|
}
|
||||||
return { error: "file ends before the image data" };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The common EXIF fields of an original. Each is absent when the file lacks it.
|
// The common EXIF fields of an original. Each is absent when the file lacks it.
|
||||||
@@ -63,9 +37,9 @@ export interface PhotoExif {
|
|||||||
model?: string;
|
model?: string;
|
||||||
lensModel?: string;
|
lensModel?: string;
|
||||||
// When the photo was taken, by the camera's clock. EXIF writes this as text
|
// When the photo was taken, by the camera's clock. EXIF writes this as text
|
||||||
// with no time zone, and exif-reader reads that text as if it were UTC: the
|
// with no time zone, and it is read as if it were UTC: the Date's UTC
|
||||||
// Date's UTC fields are the clock reading, which is the moment it was taken
|
// fields are the clock reading, which is the moment it was taken only when
|
||||||
// only when the clock was set to UTC.
|
// the clock was set to UTC.
|
||||||
dateTimeOriginal?: Date;
|
dateTimeOriginal?: Date;
|
||||||
// The camera clock's offset from UTC, such as "+02:00".
|
// The camera clock's offset from UTC, such as "+02:00".
|
||||||
offsetTimeOriginal?: string;
|
offsetTimeOriginal?: string;
|
||||||
@@ -84,56 +58,52 @@ export interface PhotoExif {
|
|||||||
gpsAltitude?: number;
|
gpsAltitude?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exifreader gives "<faulty value>" for a tag whose value lies outside the
|
||||||
|
// file; that tag is left out like one the file lacks.
|
||||||
const asString = (v: unknown): string | undefined =>
|
const asString = (v: unknown): string | undefined =>
|
||||||
typeof v === "string" && v.length > 0 ? v : undefined;
|
typeof v === "string" && v.length > 0 && v !== "<faulty value>"
|
||||||
|
? v
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const asNumber = (v: unknown): number | undefined =>
|
const asNumber = (v: unknown): number | undefined =>
|
||||||
typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||||
|
|
||||||
const asDate = (v: unknown): Date | undefined =>
|
// EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading
|
||||||
v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined;
|
// in a Date's UTC fields.
|
||||||
|
const asDate = (v: unknown): Date | undefined => {
|
||||||
// EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds.
|
const m =
|
||||||
// This is them as decimal degrees, negated when `negative`.
|
typeof v === "string"
|
||||||
const asDegrees = (v: unknown, negative: boolean): number | undefined => {
|
? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v)
|
||||||
if (!Array.isArray(v) || v.length !== 3) return undefined;
|
: null;
|
||||||
const [d, m, s] = v.map(asNumber);
|
if (!m) return undefined;
|
||||||
if (d === undefined || m === undefined || s === undefined) return undefined;
|
const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`);
|
||||||
const degrees = d + m / 60 + s / 3600;
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||||
return negative ? -degrees : degrees;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG,
|
// The common fields of an original's EXIF: `{}` when the file has no EXIF,
|
||||||
// have no EXIF block, or exif-reader cannot parse it.
|
// exifreader cannot read its EXIF, or it is not an image exifreader reads.
|
||||||
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => {
|
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => {
|
||||||
const { exif } = extractExifFromJpeg(bytes);
|
const tags = readExifTags(bytes);
|
||||||
if (exif === undefined) return {};
|
const exif = tags?.exif;
|
||||||
let tags: ReturnType<typeof exifReader>;
|
const gps = tags?.gps;
|
||||||
try {
|
const altitude = asNumber(exif?.GPSAltitude?.computed);
|
||||||
tags = exifReader(exif);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const image = tags.Image ?? {};
|
|
||||||
const photo = tags.Photo ?? {};
|
|
||||||
const gps = tags.GPSInfo ?? {};
|
|
||||||
const altitude = asNumber(gps.GPSAltitude);
|
|
||||||
const fields: PhotoExif = {
|
const fields: PhotoExif = {
|
||||||
make: asString(image.Make),
|
make: asString(exif?.Make?.computed),
|
||||||
model: asString(image.Model),
|
model: asString(exif?.Model?.computed),
|
||||||
lensModel: asString(photo.LensModel),
|
lensModel: asString(exif?.LensModel?.computed),
|
||||||
dateTimeOriginal: asDate(photo.DateTimeOriginal),
|
dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed),
|
||||||
offsetTimeOriginal: asString(photo.OffsetTimeOriginal),
|
offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed),
|
||||||
exposureTime: asNumber(photo.ExposureTime),
|
exposureTime: asNumber(exif?.ExposureTime?.computed),
|
||||||
fNumber: asNumber(photo.FNumber),
|
fNumber: asNumber(exif?.FNumber?.computed),
|
||||||
iso: asNumber(photo.ISOSpeedRatings),
|
// Only when the tag holds a single number, as most cameras write it.
|
||||||
focalLength: asNumber(photo.FocalLength),
|
iso: asNumber(exif?.ISOSpeedRatings?.computed),
|
||||||
orientation: asNumber(image.Orientation),
|
focalLength: asNumber(exif?.FocalLength?.computed),
|
||||||
gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"),
|
orientation: asNumber(exif?.Orientation?.computed),
|
||||||
gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"),
|
gpsLatitude: asNumber(gps?.Latitude),
|
||||||
|
gpsLongitude: asNumber(gps?.Longitude),
|
||||||
// A GPSAltitudeRef of 1 means the altitude is below sea level.
|
// A GPSAltitudeRef of 1 means the altitude is below sea level.
|
||||||
gpsAltitude:
|
gpsAltitude:
|
||||||
altitude !== undefined && gps.GPSAltitudeRef === 1
|
altitude !== undefined && exif?.GPSAltitudeRef?.value === 1
|
||||||
? -altitude
|
? -altitude
|
||||||
: altitude,
|
: altitude,
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-3
@@ -130,9 +130,10 @@ export class Photo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The common EXIF fields of the original, read from `content()`, so this
|
// The common EXIF fields of the original, read from `content()`, so this
|
||||||
// may download it. Only a JPEG's EXIF is read; any other file gives `{}`,
|
// may download it. EXIF is read from any image format exifreader reads,
|
||||||
// and a video gives it without fetching anything. Like the other content
|
// JPEG and HEIC/HEIF among them; any other file gives `{}`, and a video
|
||||||
// methods, it throws when there is no content cache, video or not.
|
// gives it without fetching anything. Like the other content methods, it
|
||||||
|
// throws when there is no content cache, video or not.
|
||||||
async exif(opts?: ContentOptions): Promise<PhotoExif> {
|
async exif(opts?: ContentOptions): Promise<PhotoExif> {
|
||||||
this.cacheOrThrow();
|
this.cacheOrThrow();
|
||||||
if (this.rec.fileType === "video") return {};
|
if (this.rec.fileType === "video") return {};
|
||||||
|
|||||||
+16
-14
@@ -1,9 +1,8 @@
|
|||||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import * as jpeg from "jpeg-js";
|
import * as jpeg from "jpeg-js";
|
||||||
import exifReader from "exif-reader";
|
|
||||||
import type { Client } from "./client.js";
|
import type { Client } from "./client.js";
|
||||||
import { extractExifFromJpeg } from "./exif.js";
|
import { readExifTags } from "./exif.js";
|
||||||
import type { Library, Photo } from "./library/index.js";
|
import type { Library, Photo } from "./library/index.js";
|
||||||
import { sanitizeFileName } from "./filename.js";
|
import { sanitizeFileName } from "./filename.js";
|
||||||
import {
|
import {
|
||||||
@@ -20,9 +19,10 @@ export interface MetadataBackupOptions {
|
|||||||
onProgress?: ProgressCallback;
|
onProgress?: ProgressCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment
|
// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags
|
||||||
// is malformed or cannot be parsed, the record carries the reason in
|
// exifreader returns, from any image format it reads. When it finds an EXIF
|
||||||
// `exifError`.
|
// block but reads no tag from it, the record keeps the block's bytes, base64,
|
||||||
|
// in `exifRaw`, with the reason in `exifError`.
|
||||||
export const extractImageMetadata = (
|
export const extractImageMetadata = (
|
||||||
fileBytes: Uint8Array,
|
fileBytes: Uint8Array,
|
||||||
): Record<string, unknown> | undefined => {
|
): Record<string, unknown> | undefined => {
|
||||||
@@ -39,19 +39,21 @@ export const extractImageMetadata = (
|
|||||||
result.height = decoded.height;
|
result.height = decoded.height;
|
||||||
} catch {
|
} catch {
|
||||||
// Not every original is a JPEG (PNG, HEIC, video), so a failed decode
|
// Not every original is a JPEG (PNG, HEIC, video), so a failed decode
|
||||||
// is expected and only means no dimensions; a malformed JPEG is still
|
// is expected and only means no dimensions; unreadable EXIF is still
|
||||||
// reported below through `exifError`.
|
// reported below through `exifError`.
|
||||||
}
|
}
|
||||||
|
|
||||||
const { exif, error } = extractExifFromJpeg(fileBytes);
|
const tags = readExifTags(fileBytes);
|
||||||
if (error) result.exifError = error;
|
if (tags?.exif && Object.keys(tags.exif).length > 0) {
|
||||||
if (exif) {
|
result.exif = tags.exif;
|
||||||
try {
|
} else if (tags?.exif) {
|
||||||
result.exif = exifReader(exif);
|
const block = tags.metadataRange?.blocks.find((b) => b.type === "exif");
|
||||||
} catch (err) {
|
if (block) {
|
||||||
result.exifRaw = exif.toString("base64");
|
result.exifRaw = Buffer.from(
|
||||||
result.exifError = err instanceof Error ? err.message : String(err);
|
fileBytes.subarray(block.start, block.end),
|
||||||
|
).toString("base64");
|
||||||
}
|
}
|
||||||
|
result.exifError = "no tag could be read from the EXIF block";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
|
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import type { ContentSource } from "../../src/library/content.js";
|
|||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
import { init, toBase64 } from "../../src/crypto/index.js";
|
import { init, toBase64 } from "../../src/crypto/index.js";
|
||||||
import { defaultCacheDirectory } from "../../src/library/index.js";
|
import { defaultCacheDirectory } from "../../src/library/index.js";
|
||||||
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||||
import {
|
import {
|
||||||
asLivePhoto,
|
asLivePhoto,
|
||||||
cdnSource,
|
cdnSource,
|
||||||
@@ -653,6 +654,31 @@ describe("a live photo", () => {
|
|||||||
height: 4,
|
height: 4,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("backup-metadata --exif records the EXIF of a HEIC image", async () => {
|
||||||
|
const dir = join(root, "dump");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await backupMetadataCommand(
|
||||||
|
context(await livePhotoClient(HEIC_WITH_EXIF)),
|
||||||
|
dir,
|
||||||
|
{ exif: true },
|
||||||
|
),
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
const record = JSON.parse(
|
||||||
|
readFileSync(
|
||||||
|
join(dir, "collections", "1-Vacation", "300.json"),
|
||||||
|
"utf-8",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(record.imageMetadata.exifError).toBeUndefined();
|
||||||
|
expect(record.imageMetadata.exif).toMatchObject({
|
||||||
|
Make: { value: ["Canon"] },
|
||||||
|
Model: { value: ["EOS R5"] },
|
||||||
|
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backup", () => {
|
describe("backup", () => {
|
||||||
|
|||||||
+175
-71
@@ -1,19 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
|
* Tests for reading EXIF (`src/exif.ts`) and the image metadata
|
||||||
|
* `quak backup-metadata --exif` records.
|
||||||
*
|
*
|
||||||
* The originals come from users' libraries, so a truncated or corrupt JPEG
|
* The originals come from users' libraries, so a truncated or corrupt file
|
||||||
* must neither hang the scan nor throw out of it, and a malformed file must be
|
* must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`,
|
||||||
* told apart from one that simply has no EXIF: the record carries the reason in
|
* and `backup-metadata` tells an EXIF block it cannot read apart from a file
|
||||||
* `exifError`. Each input below is a short hand-built byte array.
|
* that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below
|
||||||
|
* is a short hand-built byte array; the HEIC is a real file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { extractExifFromJpeg } from "../../src/exif.js";
|
import { readPhotoExif } from "../../src/exif.js";
|
||||||
import { extractImageMetadata } from "../../src/metadata-backup.js";
|
import { extractImageMetadata } from "../../src/metadata-backup.js";
|
||||||
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||||
|
|
||||||
const SOI = [0xff, 0xd8]; // start of image
|
const SOI = [0xff, 0xd8]; // start of image
|
||||||
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
|
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan
|
||||||
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
||||||
|
const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
||||||
|
const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00];
|
||||||
|
|
||||||
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
|
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
|
||||||
const TIFF_ORIENTATION_6 = [
|
const TIFF_ORIENTATION_6 = [
|
||||||
@@ -22,6 +27,66 @@ const TIFF_ORIENTATION_6 = [
|
|||||||
0x00, 0x00,
|
0x00, 0x00,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// A big-endian TIFF block holding Orientation 6 and DateTimeOriginal
|
||||||
|
// "0000:00:00 00:00:00", which a camera with an unset clock writes.
|
||||||
|
const TIFF_UNSET_DATE = [
|
||||||
|
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
||||||
|
// The first IFD, at 8: two entries, then no next IFD.
|
||||||
|
...[0x00, 0x02],
|
||||||
|
// Orientation (0x0112), SHORT, 6.
|
||||||
|
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
|
||||||
|
// The Exif IFD's offset (0x8769), LONG, 38.
|
||||||
|
...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26],
|
||||||
|
...[0x00, 0x00, 0x00, 0x00],
|
||||||
|
// The Exif IFD, at 38: one entry, then no next IFD.
|
||||||
|
...[0x00, 0x01],
|
||||||
|
// DateTimeOriginal (0x9003), 20 ASCII bytes at 56.
|
||||||
|
...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38],
|
||||||
|
...[0x00, 0x00, 0x00, 0x00],
|
||||||
|
...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56
|
||||||
|
];
|
||||||
|
|
||||||
|
// A big-endian TIFF block holding a GPSAltitude of 12.5 m and no
|
||||||
|
// GPSAltitudeRef.
|
||||||
|
const TIFF_ALTITUDE_WITHOUT_REF = [
|
||||||
|
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
||||||
|
// The first IFD, at 8: one entry, then no next IFD.
|
||||||
|
...[0x00, 0x01],
|
||||||
|
// The GPS IFD's offset (0x8825), LONG, 26.
|
||||||
|
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
|
||||||
|
...[0x00, 0x00, 0x00, 0x00],
|
||||||
|
// The GPS IFD, at 26: one entry, then no next IFD.
|
||||||
|
...[0x00, 0x01],
|
||||||
|
// GPSAltitude (0x0006), one RATIONAL at 44.
|
||||||
|
...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c],
|
||||||
|
...[0x00, 0x00, 0x00, 0x00],
|
||||||
|
...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44
|
||||||
|
];
|
||||||
|
|
||||||
|
// A big-endian TIFF block holding Orientation 6 and a Make whose value lies
|
||||||
|
// past the end of the file.
|
||||||
|
const TIFF_MAKE_PAST_END = [
|
||||||
|
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
||||||
|
// The first IFD, at 8: two entries, then no next IFD.
|
||||||
|
...[0x00, 0x02],
|
||||||
|
// Make (0x010f), 6 ASCII bytes at 4096.
|
||||||
|
...[0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x10, 0x00],
|
||||||
|
// Orientation (0x0112), SHORT, 6.
|
||||||
|
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
|
||||||
|
...[0x00, 0x00, 0x00, 0x00],
|
||||||
|
];
|
||||||
|
|
||||||
|
// A big-endian TIFF block with one IFD entry that exifreader has no name for:
|
||||||
|
// tag 0xc000 (49152), SHORT, 7.
|
||||||
|
const TIFF_UNNAMED_TAG = [
|
||||||
|
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
|
||||||
|
// The first IFD, at 8: one entry, then no next IFD.
|
||||||
|
...[0x00, 0x01],
|
||||||
|
// Tag 0xc000, SHORT, 7.
|
||||||
|
...[0xc0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00],
|
||||||
|
...[0x00, 0x00, 0x00, 0x00],
|
||||||
|
];
|
||||||
|
|
||||||
// An APP1 segment whose length field matches its data.
|
// An APP1 segment whose length field matches its data.
|
||||||
const app1 = (data: number[]): number[] => {
|
const app1 = (data: number[]): number[] => {
|
||||||
const len = data.length + 2;
|
const len = data.length + 2;
|
||||||
@@ -31,74 +96,87 @@ const app1 = (data: number[]): number[] => {
|
|||||||
const bytes = (...parts: number[][]): Uint8Array =>
|
const bytes = (...parts: number[][]): Uint8Array =>
|
||||||
new Uint8Array(parts.flat());
|
new Uint8Array(parts.flat());
|
||||||
|
|
||||||
describe("extractExifFromJpeg", () => {
|
describe("readPhotoExif", () => {
|
||||||
it("returns the EXIF segment of a valid JPEG", () => {
|
it("reads the common fields of a valid JPEG", () => {
|
||||||
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
||||||
const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS));
|
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
||||||
expect(scan.error).toBeUndefined();
|
orientation: 6,
|
||||||
expect([...scan.exif!]).toEqual(data);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns nothing for a file that is not a JPEG", () => {
|
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => {
|
||||||
const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE];
|
||||||
expect(extractExifFromJpeg(png)).toEqual({});
|
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
||||||
|
orientation: 6,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns nothing for a JPEG without EXIF", () => {
|
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => {
|
||||||
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF];
|
||||||
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
|
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
||||||
|
gpsAltitude: 12.5,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores an APP1 segment too short to hold the Exif header", () => {
|
it("gives no make for a Make whose value lies past the end of the file", () => {
|
||||||
// A length under 8 cannot hold the six-byte "Exif\0\0" header, so the
|
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END];
|
||||||
// segment is not EXIF. This one has length 7 and holds only "Exif\0",
|
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
||||||
// which the old code, lacking the length check, returned as EXIF.
|
orientation: 6,
|
||||||
const short = app1(EXIF_HEADER.slice(0, 5));
|
});
|
||||||
expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts an APP1 segment of length 8 holding just the Exif header", () => {
|
it.each([
|
||||||
const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS));
|
[
|
||||||
expect(scan.error).toBeUndefined();
|
"a file that is not an image",
|
||||||
expect([...scan.exif!]).toEqual(EXIF_HEADER);
|
new TextEncoder().encode("just some text, not an image"),
|
||||||
});
|
],
|
||||||
|
[
|
||||||
it("reports a JPEG truncated inside a segment header", () => {
|
"a PNG without EXIF",
|
||||||
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
|
bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
expect(scan.exif).toBeUndefined();
|
],
|
||||||
expect(scan.error).toMatch(/truncated segment length/);
|
["a JPEG without EXIF", bytes(SOI, APP0, SOS)],
|
||||||
});
|
// A length under 8 cannot hold the six-byte "Exif\0\0" header. This one
|
||||||
|
// has length 7 and holds only "Exif\0", so a read past its end would
|
||||||
it("reports a JPEG that ends before the image data", () => {
|
// take the next segment's bytes as EXIF.
|
||||||
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
[
|
||||||
const scan = extractExifFromJpeg(bytes(SOI, app0));
|
"an APP1 segment too short to hold the Exif header",
|
||||||
expect(scan.error).toMatch(/ends before the image data/);
|
bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS),
|
||||||
});
|
],
|
||||||
|
[
|
||||||
it("stops on a zero-length segment instead of looping", () => {
|
"an APP1 segment holding just the Exif header",
|
||||||
// A length of 0 would otherwise step the scan by 2 bytes at a time
|
bytes(SOI, app1(EXIF_HEADER), SOS),
|
||||||
// through the rest of the file, reading garbage as markers.
|
],
|
||||||
const zero = [0xff, 0xe0, 0x00, 0x00];
|
[
|
||||||
const scan = extractExifFromJpeg(
|
"a JPEG truncated inside a segment header",
|
||||||
bytes(SOI, zero, zero, zero, zero, SOS),
|
bytes(SOI, [0xff, 0xe1, 0x00]),
|
||||||
);
|
],
|
||||||
expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
|
["a JPEG that ends before the image data", bytes(SOI, APP0)],
|
||||||
});
|
// A length of 0 would step a scan by 2 bytes at a time through the
|
||||||
|
// rest of the file, reading garbage as markers.
|
||||||
it("stops on a segment length of 1", () => {
|
[
|
||||||
const scan = extractExifFromJpeg(
|
"a zero-length segment",
|
||||||
bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
|
bytes(
|
||||||
);
|
SOI,
|
||||||
expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/);
|
ZERO_LENGTH_APP0,
|
||||||
});
|
ZERO_LENGTH_APP0,
|
||||||
|
ZERO_LENGTH_APP0,
|
||||||
it("reports a segment length that runs past the end of the file", () => {
|
ZERO_LENGTH_APP0,
|
||||||
|
SOS,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)],
|
||||||
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
|
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
|
||||||
const scan = extractExifFromJpeg(
|
[
|
||||||
|
"a segment length that runs past the end of the file",
|
||||||
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
||||||
);
|
],
|
||||||
expect(scan.exif).toBeUndefined();
|
// "XX" where the TIFF byte order belongs.
|
||||||
expect(scan.error).toMatch(/runs past the end of the file/);
|
[
|
||||||
|
"an EXIF block that cannot be parsed",
|
||||||
|
bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS),
|
||||||
|
],
|
||||||
|
])("returns no fields for %s", (_, input) => {
|
||||||
|
expect(readPhotoExif(input)).toStrictEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,10 +186,30 @@ describe("extractImageMetadata", () => {
|
|||||||
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
|
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
|
||||||
);
|
);
|
||||||
expect(meta?.exifError).toBeUndefined();
|
expect(meta?.exifError).toBeUndefined();
|
||||||
expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } });
|
expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns nothing for a file that is not a JPEG", () => {
|
it("parses EXIF from a HEIC", () => {
|
||||||
|
const meta = extractImageMetadata(HEIC_WITH_EXIF);
|
||||||
|
expect(meta?.exifError).toBeUndefined();
|
||||||
|
expect(meta?.exif).toMatchObject({
|
||||||
|
Make: { value: ["Canon"] },
|
||||||
|
Model: { value: ["EOS R5"] },
|
||||||
|
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
|
||||||
|
Orientation: { value: 6 },
|
||||||
|
GPSLatitudeRef: { value: ["N"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys a tag exifreader has no name for by its number", () => {
|
||||||
|
const meta = extractImageMetadata(
|
||||||
|
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_UNNAMED_TAG]), SOS),
|
||||||
|
);
|
||||||
|
expect(meta?.exifError).toBeUndefined();
|
||||||
|
expect(meta?.exif).toMatchObject({ "undefined-49152": { value: 7 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a file that is not an image", () => {
|
||||||
const text = new TextEncoder().encode("just some text, not an image");
|
const text = new TextEncoder().encode("just some text, not an image");
|
||||||
expect(extractImageMetadata(text)).toBeUndefined();
|
expect(extractImageMetadata(text)).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -121,14 +219,20 @@ describe("extractImageMetadata", () => {
|
|||||||
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
|
||||||
);
|
);
|
||||||
expect(meta?.exif).toBeUndefined();
|
expect(meta?.exif).toBeUndefined();
|
||||||
expect(meta?.exifError).toMatch(/runs past the end of the file/);
|
expect(meta?.exifError).toBe(
|
||||||
|
"no tag could be read from the EXIF block",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
|
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
|
||||||
const data = [...EXIF_HEADER, 0x58, 0x58];
|
// The raw bytes are the whole EXIF block as exifreader finds it: for a
|
||||||
const meta = extractImageMetadata(bytes(SOI, app1(data), SOS));
|
// JPEG, the APP1 segment, marker and length included.
|
||||||
|
const segment = app1([...EXIF_HEADER, 0x58, 0x58]);
|
||||||
|
const meta = extractImageMetadata(bytes(SOI, segment, SOS));
|
||||||
expect(meta?.exif).toBeUndefined();
|
expect(meta?.exif).toBeUndefined();
|
||||||
expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64"));
|
expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64"));
|
||||||
expect(meta?.exifError).toEqual(expect.any(String));
|
expect(meta?.exifError).toBe(
|
||||||
|
"no tag could be read from the EXIF block",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { ContentSource } from "../../src/library/content.js";
|
||||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
import { asLivePhoto, cdnSource, IMAGE, livePhotoZip } from "../live-photo.js";
|
import type { PhotoExif } from "../../src/exif.js";
|
||||||
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||||
|
import {
|
||||||
|
asLivePhoto,
|
||||||
|
cdnSource,
|
||||||
|
IMAGE,
|
||||||
|
livePhotoHash,
|
||||||
|
livePhotoZip,
|
||||||
|
VIDEO,
|
||||||
|
} from "../live-photo.js";
|
||||||
|
|
||||||
const USER_ID = 7;
|
const USER_ID = 7;
|
||||||
|
|
||||||
@@ -297,7 +306,7 @@ const JPEG_WITH_EXIF = new Uint8Array([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
|
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
|
||||||
// byte order belongs, so exif-reader cannot parse it.
|
// byte order belongs, so exifreader cannot parse it.
|
||||||
const JPEG_WITH_BAD_EXIF = new Uint8Array([
|
const JPEG_WITH_BAD_EXIF = new Uint8Array([
|
||||||
...[0xff, 0xd8], // start of image
|
...[0xff, 0xd8], // start of image
|
||||||
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
|
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
|
||||||
@@ -411,7 +420,49 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
|||||||
await lib.close();
|
await lib.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns no EXIF fields for an original that is not a JPEG", async () => {
|
// What exif() returns for HEIC_WITH_EXIF, which holds the same values as
|
||||||
|
// JPEG_WITH_EXIF.
|
||||||
|
const heicFields: PhotoExif = {
|
||||||
|
make: "Canon",
|
||||||
|
model: "EOS R5",
|
||||||
|
lensModel: "RF50mm F1.8 STM",
|
||||||
|
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
|
||||||
|
offsetTimeOriginal: "+02:00",
|
||||||
|
exposureTime: 1 / 250,
|
||||||
|
fNumber: 2.8,
|
||||||
|
iso: 400,
|
||||||
|
focalLength: 50,
|
||||||
|
orientation: 6,
|
||||||
|
gpsLatitude: 40 + 26 / 60 + 46 / 3600,
|
||||||
|
gpsLongitude: -(79 + 58 / 60 + 56 / 3600),
|
||||||
|
gpsAltitude: -12.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reads the same common EXIF fields from a HEIC original", async () => {
|
||||||
|
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
|
||||||
|
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual(
|
||||||
|
heicFields,
|
||||||
|
);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the EXIF of a live photo whose image is a HEIC", async () => {
|
||||||
|
const { file: live, body } = await asLivePhoto(
|
||||||
|
file(1, 1),
|
||||||
|
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
|
||||||
|
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
|
||||||
|
);
|
||||||
|
const lib = await open({
|
||||||
|
client: new FilesClient([live]),
|
||||||
|
contentSource: cdnSource(new Map([[1, body]])),
|
||||||
|
});
|
||||||
|
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual(
|
||||||
|
heicFields,
|
||||||
|
);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no EXIF fields for an original that is not an image", async () => {
|
||||||
const lib = await open();
|
const lib = await open();
|
||||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
|
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
|
||||||
await lib.close();
|
await lib.close();
|
||||||
|
|||||||
@@ -702,6 +702,11 @@
|
|||||||
loupe "^3.1.2"
|
loupe "^3.1.2"
|
||||||
tinyrainbow "^1.2.0"
|
tinyrainbow "^1.2.0"
|
||||||
|
|
||||||
|
"@xmldom/xmldom@^0.9.10":
|
||||||
|
version "0.9.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz#1f84c07cb95ccf28202299f77b5fd7fc257151e8"
|
||||||
|
integrity sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==
|
||||||
|
|
||||||
acorn-jsx@^5.3.2:
|
acorn-jsx@^5.3.2:
|
||||||
version "5.3.2"
|
version "5.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||||
@@ -1002,10 +1007,12 @@ esutils@^2.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||||
|
|
||||||
exif-reader@2.0.3:
|
exifreader@4.46.0:
|
||||||
version "2.0.3"
|
version "4.46.0"
|
||||||
resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d"
|
resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f"
|
||||||
integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==
|
integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q==
|
||||||
|
optionalDependencies:
|
||||||
|
"@xmldom/xmldom" "^0.9.10"
|
||||||
|
|
||||||
expect-type@^1.1.0:
|
expect-type@^1.1.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user