Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m18s
check / check (push) Successful in 1m18s
A Photo now has savePath and isLocal, which look only at the disk; content() and exif(), which may download the original; and modifiedAt, hash, fileSize and year. PhotoRecord gains modifiedAt, hash and fileSize. The JPEG EXIF scan moves from metadata-backup.ts to the new src/exif.ts, so the read surface does not import the backup command. Model: opus-5-5
This commit is contained in:
+145
@@ -0,0 +1,145 @@
|
||||
// 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.
|
||||
|
||||
import exifReader from "exif-reader";
|
||||
|
||||
// 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;
|
||||
}
|
||||
return { error: "file ends before the image data" };
|
||||
};
|
||||
|
||||
// The common EXIF fields of an original. Each is absent when the file lacks it.
|
||||
export interface PhotoExif {
|
||||
make?: string;
|
||||
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.
|
||||
dateTimeOriginal?: Date;
|
||||
// The camera clock's offset from UTC, such as "+02:00".
|
||||
offsetTimeOriginal?: string;
|
||||
// Seconds.
|
||||
exposureTime?: number;
|
||||
fNumber?: number;
|
||||
iso?: number;
|
||||
// Millimetres.
|
||||
focalLength?: number;
|
||||
// The EXIF orientation code, 1 to 8.
|
||||
orientation?: number;
|
||||
// Decimal degrees, negative south of the equator and west of Greenwich.
|
||||
gpsLatitude?: number;
|
||||
gpsLongitude?: number;
|
||||
// Metres, negative below sea level.
|
||||
gpsAltitude?: number;
|
||||
}
|
||||
|
||||
const asString = (v: unknown): string | undefined =>
|
||||
typeof v === "string" && v.length > 0 ? 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;
|
||||
};
|
||||
|
||||
// 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.
|
||||
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 fields: PhotoExif = {
|
||||
make: asString(image.Make),
|
||||
model: asString(image.Model),
|
||||
lensModel: asString(photo.LensModel),
|
||||
dateTimeOriginal: asDate(photo.DateTimeOriginal),
|
||||
offsetTimeOriginal: asString(photo.OffsetTimeOriginal),
|
||||
exposureTime: asNumber(photo.ExposureTime),
|
||||
fNumber: asNumber(photo.FNumber),
|
||||
iso: asNumber(photo.ISOSpeedRatings),
|
||||
focalLength: asNumber(photo.FocalLength),
|
||||
orientation: asNumber(image.Orientation),
|
||||
gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"),
|
||||
gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"),
|
||||
// A GPSAltitudeRef of 1 means the altitude is below sea level.
|
||||
gpsAltitude:
|
||||
altitude !== undefined && gps.GPSAltitudeRef === 1
|
||||
? -altitude
|
||||
: altitude,
|
||||
};
|
||||
// Leave out what the file lacks, so a missing field is absent rather than
|
||||
// present and undefined.
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).filter(([, v]) => v !== undefined),
|
||||
) as PhotoExif;
|
||||
};
|
||||
@@ -84,6 +84,7 @@ export type {
|
||||
LibrarySnapshot,
|
||||
LibraryChange,
|
||||
} from "./library/records.js";
|
||||
export type { PhotoExif } from "./exif.js";
|
||||
export { decryptCollection, decryptFile } from "./model/index.js";
|
||||
export { downloadFile, downloadThumbnail } from "./download/index.js";
|
||||
export type {
|
||||
|
||||
@@ -108,6 +108,8 @@ export interface ContentOptions {
|
||||
export interface PhotoContent {
|
||||
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||
savePath(fileID: number): string | undefined;
|
||||
isLocal(fileID: number): boolean;
|
||||
}
|
||||
|
||||
export interface EnsureResult {
|
||||
@@ -435,6 +437,29 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
||||
}
|
||||
|
||||
// Where a backup to the download directory stores the file's original,
|
||||
// whether or not it is there yet: for a live photo already stored, its
|
||||
// image. Undefined with no download directory.
|
||||
savePath(fileID: number): string | undefined {
|
||||
const file = this.getFile(fileID);
|
||||
if (this.downloadDirectory === undefined || file === undefined)
|
||||
return undefined;
|
||||
const dir = join(this.downloadDirectory, "originals");
|
||||
return (
|
||||
storedOriginal(dir, file)?.path ?? join(dir, nameInOriginals(file))
|
||||
);
|
||||
}
|
||||
|
||||
// Whether the whole original is in the download directory, as a backup
|
||||
// stores it. A copy only in the cache does not count.
|
||||
isLocal(fileID: number): boolean {
|
||||
const file = this.getFile(fileID);
|
||||
if (this.downloadDirectory === undefined || file === undefined)
|
||||
return false;
|
||||
const dir = join(this.downloadDirectory, "originals");
|
||||
return storedOriginal(dir, file) !== undefined;
|
||||
}
|
||||
|
||||
// Get an original for a backup. One not present anywhere is written
|
||||
// straight to `destination` and recorded there, so no second copy lands
|
||||
// in the cache; one already present is returned where it is.
|
||||
|
||||
+56
-10
@@ -11,11 +11,15 @@
|
||||
// access and, for an album, its photos. They are not sent across IPC — the
|
||||
// plain records are the serializable surface, and `record()` returns one.
|
||||
//
|
||||
// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go
|
||||
// through the on-disk content cache (issue #46), the one place in this module
|
||||
// that is not synchronous and RAM-only. A library opened without a content
|
||||
// source leaves that cache absent, and those two methods then throw.
|
||||
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
|
||||
// `content()` and `exif()` go through the on-disk content cache (issue #46),
|
||||
// and are the one place in this module that may touch the network. A library
|
||||
// opened without a content source leaves that cache absent, and those methods
|
||||
// then throw. `savePath` and `isLocal` look only at the disk.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { readPhotoExif, type PhotoExif } from "../exif.js";
|
||||
import type { CollectionType, FileType } from "../model/types.js";
|
||||
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
||||
@@ -35,7 +39,7 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
||||
export class Photo {
|
||||
constructor(
|
||||
private readonly rec: PhotoRecord,
|
||||
private readonly content?: PhotoContent,
|
||||
private readonly cache?: PhotoContent,
|
||||
) {}
|
||||
|
||||
get fileID(): number {
|
||||
@@ -50,6 +54,13 @@ export class Photo {
|
||||
get takenAt(): number {
|
||||
return this.rec.takenAt;
|
||||
}
|
||||
get modifiedAt(): number {
|
||||
return this.rec.modifiedAt;
|
||||
}
|
||||
// The local-time year of `takenAt`.
|
||||
get year(): number {
|
||||
return new Date(this.rec.takenAt).getFullYear();
|
||||
}
|
||||
get fileType(): FileType {
|
||||
return this.rec.fileType;
|
||||
}
|
||||
@@ -68,6 +79,12 @@ export class Photo {
|
||||
get longitude(): number | undefined {
|
||||
return this.rec.longitude;
|
||||
}
|
||||
get hash(): string | undefined {
|
||||
return this.rec.hash;
|
||||
}
|
||||
get fileSize(): number | undefined {
|
||||
return this.rec.fileSize;
|
||||
}
|
||||
get isArchived(): boolean {
|
||||
return this.rec.isArchived;
|
||||
}
|
||||
@@ -75,6 +92,20 @@ export class Photo {
|
||||
return this.rec.isHidden;
|
||||
}
|
||||
|
||||
// The path `lib.backup()` writes the original to in the library's download
|
||||
// directory, whether or not it is there yet; for a live photo, its image.
|
||||
// Undefined when the library has no download directory or no content
|
||||
// cache.
|
||||
get savePath(): string | undefined {
|
||||
return this.cache?.savePath(this.rec.fileID);
|
||||
}
|
||||
|
||||
// Whether the whole original is at `savePath`. A copy only in the cache
|
||||
// does not count.
|
||||
get isLocal(): boolean {
|
||||
return this.cache?.isLocal(this.rec.fileID) ?? false;
|
||||
}
|
||||
|
||||
record(): PhotoRecord {
|
||||
return this.rec;
|
||||
}
|
||||
@@ -84,21 +115,36 @@ export class Photo {
|
||||
// `videoPath`. Served from the cache (or the backup download directory)
|
||||
// when already present, otherwise fetched through the content pool.
|
||||
async original(opts?: ContentOptions): Promise<ContentResult> {
|
||||
return this.contentOrThrow().original(this.rec.fileID, opts);
|
||||
return this.cacheOrThrow().original(this.rec.fileID, opts);
|
||||
}
|
||||
|
||||
// As `original`, for the thumbnail, through the thumbnail pool.
|
||||
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
||||
return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
|
||||
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
|
||||
}
|
||||
|
||||
private contentOrThrow(): PhotoContent {
|
||||
if (!this.content) {
|
||||
// The original's bytes, read from where `original()` puts it. For a live
|
||||
// photo, its image's.
|
||||
async content(opts?: ContentOptions): Promise<Uint8Array> {
|
||||
const { path } = await this.original(opts);
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
// 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.
|
||||
async exif(opts?: ContentOptions): Promise<PhotoExif> {
|
||||
if (this.rec.fileType === "video") return {};
|
||||
return readPhotoExif(await this.content(opts));
|
||||
}
|
||||
|
||||
private cacheOrThrow(): PhotoContent {
|
||||
if (!this.cache) {
|
||||
throw new Error(
|
||||
"Photo content requires a library opened with a content cache",
|
||||
);
|
||||
}
|
||||
return this.content;
|
||||
return this.cache;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-4
@@ -5,10 +5,11 @@
|
||||
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
|
||||
// the main process; the window only ever sees these records.
|
||||
//
|
||||
// Ente holds edited/basic times in microseconds; records expose `takenAt` in
|
||||
// milliseconds. The magic-metadata field names below are the ones the Ente
|
||||
// clients write, confirmed against the repo's own fixtures: `w`/`h` in
|
||||
// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts.
|
||||
// Ente holds edited/basic times in microseconds; records expose `takenAt` and
|
||||
// `modifiedAt` in milliseconds. The magic-metadata field names below are the
|
||||
// ones the Ente clients write, confirmed against the repo's own fixtures:
|
||||
// `w`/`h` in test/cli/metadata-backup.test.ts, `visibility` in
|
||||
// test/library/store.test.ts.
|
||||
|
||||
import type {
|
||||
Collection,
|
||||
@@ -33,12 +34,19 @@ export interface PhotoRecord {
|
||||
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
|
||||
// date, else basic-metadata `creationTime`.
|
||||
takenAt: number;
|
||||
// Milliseconds. Basic-metadata `modificationTime`.
|
||||
modifiedAt: number;
|
||||
fileType: FileType;
|
||||
caption?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
// The content hash the uploader recorded (`FileMetadata.hash`); files from
|
||||
// very old clients have none.
|
||||
hash?: string;
|
||||
// The original's size in bytes, as the server reports it.
|
||||
fileSize?: number;
|
||||
isArchived: boolean;
|
||||
isHidden: boolean;
|
||||
// Local cache paths, set once a later phase caches the bytes; unset here.
|
||||
@@ -125,6 +133,7 @@ const toPhotoRecord = (
|
||||
albumIDs,
|
||||
title: asString(pub.editedName) ?? rep.metadata.title,
|
||||
takenAt: microsToMillis(takenAtMicros),
|
||||
modifiedAt: microsToMillis(rep.metadata.modificationTime),
|
||||
fileType: rep.metadata.fileType,
|
||||
isArchived: visibility === VISIBILITY_ARCHIVED,
|
||||
isHidden: visibility === VISIBILITY_HIDDEN,
|
||||
@@ -140,6 +149,8 @@ const toPhotoRecord = (
|
||||
record.latitude = rep.metadata.latitude;
|
||||
if (rep.metadata.longitude !== undefined)
|
||||
record.longitude = rep.metadata.longitude;
|
||||
if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash;
|
||||
if (rep.file.size !== undefined) record.fileSize = rep.file.size;
|
||||
|
||||
return record;
|
||||
};
|
||||
|
||||
+1
-54
@@ -3,6 +3,7 @@ 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 type { Library, Photo } from "./library/index.js";
|
||||
import { sanitizeFileName } from "./filename.js";
|
||||
import {
|
||||
@@ -19,60 +20,6 @@ export interface MetadataBackupOptions {
|
||||
onProgress?: ProgressCallback;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
return { error: "file ends before the image data" };
|
||||
};
|
||||
|
||||
// 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`.
|
||||
|
||||
Reference in New Issue
Block a user