diff --git a/README.md b/README.md index bf1d9ac..ac4a043 100644 --- a/README.md +++ b/README.md @@ -611,7 +611,8 @@ the smallest does not. below) YYYY-MM-DD..json the file's basic metadata fields quak keeps, its update time, its private and - public magic metadata, and its ML data + public magic metadata, its ML data, and + its original's EXIF, XMP and dimensions YYYY-MM-DD..livephoto.json which of a live photo's two files is which collections/ @@ -648,6 +649,16 @@ not in the cache gets the reason in `mlDataError` instead and counts as failed, and the next run fetches it again. The JSON files are rewritten on every run, so ML data that arrived since the last run appears. +A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records +from its original, or from a live photo's image: `format`, `width` and `height` +for a JPEG, `exif` (or `exifRaw` and `exifError`), and `xmp`. An original with +none of these gets `{}`. A video gets no `imageMetadata`: reading a whole video +to look for tags is not worth it. When the original cannot be read, the reason +is in `imageMetadataError` instead; neither the file nor the run fails. The +original is read when the run stores it, or when the JSON beside it has neither +field, as one an earlier version wrote. Otherwise the field is taken from that +JSON when it is rewritten, so a run does not read every stored original again. + `failures.json` records each failed file with the kind of failure, how many times it has been tried and when it was last tried. A file leaves it once it succeeds, or once it is no longer in the library or in the backup's scope. The @@ -905,13 +916,14 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` `fresh()` does, puts every in-scope original not already at its save path there as `photo.download()` does (and, with `includeThumbnails`, fetches thumbnails) through the content cache, waits for an ML data fetch, and - rebuilds the on-disk backup tree, each file's JSON with its ML data, with a - durable failure ledger. A fetched original is written straight to its save - path and not into the cache, which then counts it as present; one the cache - already held is copied from there. `BackupOptions`: `downloadDirectory` (falls - back to the library's), `includeOriginals` (default `true`), - `includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See - Backup layout above for the tree it writes. + rebuilds the on-disk backup tree, each file's JSON with its ML data and its + original's EXIF, XMP and dimensions, with a durable failure ledger. A fetched + original is written straight to its save path and not into the cache, which + then counts it as present; one the cache already held is copied from there. + `BackupOptions`: `downloadDirectory` (falls back to the library's), + `includeOriginals` (default `true`), `includeThumbnails` (default `false`), + `onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it + writes. ### Request pools diff --git a/TODO.md b/TODO.md index 3fb0506..cbd2391 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,15 @@ declares one. # Completed Steps +- 2026-10-06: `quak backup` writes each original's EXIF, XMP and dimensions into + the file's JSON as `imageMetadata`, what `backup-metadata --exif` records + (issue 167): for a live photo from its image, for a video nothing, and `{}` + for an original with none of them. A failed read puts the reason in + `imageMetadataError` and fails neither the file nor the run. An original is + read when the run stores it or when its JSON has neither field; otherwise the + field is taken from that JSON. The hand-built JPEGs moved to + `test/exif-jpeg.ts`, beside the HEIC. + - 2026-10-06: When the server answers HTTP 401 and that ends a command that loads the saved session, because the server no longer accepts its token, the command prints one line, diff --git a/src/backup.ts b/src/backup.ts index bfe1dd4..4da6aa4 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -11,7 +11,9 @@ // YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY-MM-DD.. the decrypted bytes (the save path) // YYYY-MM-DD..json per-file metadata sidecar, with -// the file's ML data +// the file's ML data and its +// original's EXIF, XMP and +// dimensions // collections// symlink to the original // collections/<name>.json per-collection metadata // account.json the account's email and user ID @@ -28,7 +30,10 @@ // no unique state, so they are rebuilt every run; that repairs stale sidecars // and missing or broken symlinks left by an earlier crash. A rebuild also // removes the symlinks to originals that no longer belong to an album, and the -// directories of albums that no longer exist. +// directories of albums that no longer exist. The one thing a sidecar takes +// from the sidecar it replaces is its original's EXIF, XMP and dimensions (or +// why they could not be read), so that a run does not read every stored +// original again; a sidecar without them gets them read from the original. // // Resilience (issue #8): no per-file condition aborts the run. A failed // download, a failed symlink, or ML data missing because the ML data fetch @@ -53,6 +58,7 @@ import { symlinkSync, writeFileSync, } from "node:fs"; +import { readFile } from "node:fs/promises"; import { dirname, extname, join, relative, resolve } from "node:path"; import { removeLeftoverTempFiles } from "./download/index.js"; @@ -64,6 +70,7 @@ import { storedAtSavePath, } from "./library/content.js"; import { representative } from "./library/records.js"; +import { extractImageMetadata } from "./metadata-backup.js"; import type { MLData } from "./mldata-fetch.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; @@ -354,12 +361,53 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => { ); }; -// The file's JSON: its basic fields, its magic metadata, and its ML data, or -// the reason the ML data is missing. +// A file's EXIF, XMP and dimensions as its JSON holds them: what +// `extractImageMetadata` found in its original, or why the original could not +// be read. +interface ImageMetadata { + imageMetadata?: Record<string, unknown>; + imageMetadataError?: string; +} + +// The image metadata for the file whose original is at `originalPath` (for a +// live photo, its image) and whose JSON is at `jsonPath`. A video gets none, +// as `photo.exif()` reads none. An original stored before this run is not read +// again when its JSON already holds image metadata: that is kept. A failed +// read gives the reason, and fails neither the file nor the run. An original +// with no EXIF, XMP or JPEG dimensions gets `{}`, so it is not read again. +const imageMetadataFor = async ( + file: EnteFile, + originalPath: string, + jsonPath: string, + storedThisRun: boolean, +): Promise<ImageMetadata> => { + if (file.metadata.fileType === "video") return {}; + if (!storedThisRun) { + try { + const { imageMetadata, imageMetadataError } = JSON.parse( + readFileSync(jsonPath, "utf-8"), + ) as ImageMetadata; + if (imageMetadata !== undefined || imageMetadataError !== undefined) + return { imageMetadata, imageMetadataError }; + } catch { + // No JSON yet, or one that cannot be parsed: read the original. + } + } + try { + const bytes = await readFile(originalPath); + return { imageMetadata: extractImageMetadata(bytes) ?? {} }; + } catch (err) { + return { imageMetadataError: errorMessage(err) }; + } +}; + +// The file's JSON: its basic fields, its magic metadata, its ML data or the +// reason the ML data is missing, and its image metadata. const writeSidecar = ( path: string, file: EnteFile, ml: { mlData?: MLData; mlDataError?: string }, + image: ImageMetadata, ): void => { const meta: Record<string, unknown> = { id: file.id, @@ -372,6 +420,10 @@ const writeSidecar = ( if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; if (ml.mlData) meta.mlData = ml.mlData; if (ml.mlDataError) meta.mlDataError = ml.mlDataError; + if (image.imageMetadata) meta.imageMetadata = image.imageMetadata; + if (image.imageMetadataError) { + meta.imageMetadataError = image.imageMetadataError; + } writeFileSync(path, JSON.stringify(meta, null, 2)); }; @@ -468,6 +520,7 @@ export const runBackup = async ( const errors: BackupError[] = []; const failedThisRun = new Set<number>(); + const storedThisRun = new Set<number>(); let downloaded = 0; let skipped = 0; @@ -516,6 +569,7 @@ export const runBackup = async ( await placeOriginal(downloadDirectory, file, (dest) => lib.original(fileID, dest), ); + storedThisRun.add(fileID); downloaded++; } catch (err) { log( @@ -549,9 +603,10 @@ export const runBackup = async ( // Phase 2: rebuild the derived views from the model. Sidecars first, for // every present original (this repairs stale ones), each with the file's - // ML data once an ML data fetch has completed. When the fetch fails, a - // file with no cached ML data gets the reason instead and is recorded as - // failed. The next run fetches its ML data again because none is cached. + // ML data once an ML data fetch has completed, and its image metadata. + // When the fetch fails, a file with no cached ML data gets the reason + // instead and is recorded as failed. The next run fetches its ML data + // again because none is cached. if (includeOriginals) { let mlDataError: string | undefined; try { @@ -562,23 +617,28 @@ export const runBackup = async ( log(`FAILED ML data: ${mlDataError}`); } for (const file of distinct.values()) { - if (storedAtSavePath(downloadDirectory, file) === undefined) { - continue; - } + const stored = storedAtSavePath(downloadDirectory, file); + if (stored === undefined) continue; const path = withExtension( savePath(downloadDirectory, file), ".json", ); + const image = await imageMetadataFor( + file, + stored.path, + path, + storedThisRun.has(file.id), + ); const mlData = await lib.mlData(file.id); if (mlData === undefined && mlDataError !== undefined) { - writeSidecar(path, file, { mlDataError }); + writeSidecar(path, file, { mlDataError }, image); recordFailure( file, collectionName.get(file.collectionID) ?? "", new Error(`ML data: ${mlDataError}`), ); } else { - writeSidecar(path, file, { mlData }); + writeSidecar(path, file, { mlData }, image); } } } diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts index 5424242..b30ad5c 100644 --- a/test/cli/backup.test.ts +++ b/test/cli/backup.test.ts @@ -33,6 +33,7 @@ */ import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -53,12 +54,16 @@ import { runBackup, type BackupLibrary } from "../../src/backup.js"; import { Library } from "../../src/library/index.js"; import type { ContentSource } from "../../src/library/content.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js"; +import { extractImageMetadata } from "../../src/metadata-backup.js"; import type { MLData } from "../../src/mldata-fetch.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; +import { HEIC_WITH_EXIF } from "../exif-heic.js"; +import { JPEG_WITH_EXIF } from "../exif-jpeg.js"; import { asLivePhoto, cdnSource, IMAGE, + livePhotoHash, livePhotoZip, VIDEO, } from "../live-photo.js"; @@ -1018,6 +1023,179 @@ describe("account and album records", () => { }); }); +// What a file's JSON holds as `imageMetadata` for an original of `bytes`. +const imageMetadataOf = (bytes: Uint8Array): unknown => + JSON.parse(JSON.stringify(extractImageMetadata(bytes))); + +describe("image metadata in each file's JSON", () => { + // Serves `files` in the Vacation album and nothing in Work. + class FilesClient extends MockClient { + constructor(private readonly files: EnteFile[]) { + super(); + } + override async filesSince(args: { + collectionID: number; + }): Promise<FilesPage> { + return { + files: args.collectionID === 1 ? this.files : [], + deleted: [], + cursor: 1, + }; + } + } + + // Writes `originals.get(fileID)` as each file's original, looked up at + // each fetch, so a test can change it between runs. + const bytesSource = ( + originals: Map<number, Uint8Array>, + ): ContentSource => ({ + original: async ({ file: f, destination }) => { + const bytes = originals.get(f.id)!; + writeFileSync(destination, bytes); + return { bytesWritten: bytes.length }; + }, + thumbnail: async () => { + throw new Error("no thumbnails in this source"); + }, + }); + + // A JPEG, a HEIC, a video, and a file holding no image metadata. The + // video's bytes are the JPEG's, so reading it would find EXIF. + const clip = file(602, 1, "clip.mov"); + const files = [ + file(600, 1, "photo.jpg"), + file(601, 1, "photo.heic"), + { ...clip, metadata: { ...clip.metadata, fileType: "video" as const } }, + file(603, 1, "notes.png"), + ]; + const originals = (): Map<number, Uint8Array> => + new Map([ + [600, JPEG_WITH_EXIF], + [601, HEIC_WITH_EXIF], + [602, JPEG_WITH_EXIF], + [603, new TextEncoder().encode("not an image")], + ]); + const open = (bytes = originals()): Promise<Library> => + openLibrary(bytesSource(bytes), new FilesClient(files)); + + // Rewrite the JSON of `fileID` without its image metadata, as an earlier + // version of quak wrote it. + const dropImageMetadata = (outDir: string, fileID: number): void => { + const json = fileJSON(outDir, fileID); + delete json.imageMetadata; + writeFileSync(saved(outDir, `${fileID}.json`), JSON.stringify(json)); + }; + + it("writes each new original's image metadata, and none for a video", async () => { + const lib = await open(); + const outDir = join(root, "backup"); + + const result = await lib.backup({ downloadDirectory: outDir }); + + expect(result).toMatchObject({ downloaded: 4, failed: 0 }); + expect(fileJSON(outDir, 600).imageMetadata).toEqual( + imageMetadataOf(JPEG_WITH_EXIF), + ); + expect(fileJSON(outDir, 601).imageMetadata).toEqual( + imageMetadataOf(HEIC_WITH_EXIF), + ); + expect(fileJSON(outDir, 602)).not.toHaveProperty("imageMetadata"); + expect(fileJSON(outDir, 602)).not.toHaveProperty("imageMetadataError"); + // Empty, so that the next run does not read it again. + expect(fileJSON(outDir, 603).imageMetadata).toEqual({}); + await lib.close(); + }); + + it("reads a live photo's image", async () => { + const { file: live, body } = await asLivePhoto( + file(500, 1, "IMG_0500.HEIC"), + livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }), + livePhotoHash(HEIC_WITH_EXIF, VIDEO), + ); + const lib = await openLibrary( + cdnSource(new Map([[500, body]])), + new FilesClient([live]), + ); + const outDir = join(root, "backup"); + + await lib.backup({ downloadDirectory: outDir }); + + expect(fileJSON(outDir, 500).imageMetadata).toEqual( + imageMetadataOf(HEIC_WITH_EXIF), + ); + await lib.close(); + }); + + it("keeps the image metadata of an original stored by an earlier run without reading it again", async () => { + const lib = await open(); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + // A read of the original now would give the HEIC's. + writeFileSync(saved(outDir, "600.jpg"), HEIC_WITH_EXIF); + + const second = await lib.backup({ downloadDirectory: outDir }); + + expect(second).toMatchObject({ downloaded: 0, failed: 0 }); + expect(fileJSON(outDir, 600).imageMetadata).toEqual( + imageMetadataOf(JPEG_WITH_EXIF), + ); + await lib.close(); + }); + + it("reads an original stored by an earlier version, whose JSON has no image metadata", async () => { + const lib = await open(); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + dropImageMetadata(outDir, 600); + + const second = await lib.backup({ downloadDirectory: outDir }); + + expect(second).toMatchObject({ downloaded: 0, failed: 0 }); + expect(fileJSON(outDir, 600).imageMetadata).toEqual( + imageMetadataOf(JPEG_WITH_EXIF), + ); + await lib.close(); + }); + + it("reads an original the run stores again, though its JSON has image metadata", async () => { + const bytes = originals(); + const lib = await open(bytes); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + rmSync(saved(outDir, "600.jpg")); + bytes.set(600, HEIC_WITH_EXIF); + + const second = await lib.backup({ downloadDirectory: outDir }); + + expect(second).toMatchObject({ downloaded: 1, failed: 0 }); + expect(fileJSON(outDir, 600).imageMetadata).toEqual( + imageMetadataOf(HEIC_WITH_EXIF), + ); + await lib.close(); + }); + + // Root ignores file permissions, so this fails when run as root. The + // `test` phase of the `Dockerfile` runs as the `node` user. + it("gives the reason an original could not be read, failing neither the file nor the run", async () => { + const lib = await open(); + const outDir = join(root, "backup"); + await lib.backup({ downloadDirectory: outDir }); + dropImageMetadata(outDir, 600); + const original = saved(outDir, "600.jpg"); + chmodSync(original, 0o000); + + const second = await lib + .backup({ downloadDirectory: outDir }) + .finally(() => chmodSync(original, 0o600)); + + expect(second).toMatchObject({ failed: 0, errors: [] }); + expect(existsSync(join(outDir, "failures.json"))).toBe(false); + expect(fileJSON(outDir, 600)).not.toHaveProperty("imageMetadata"); + expect(fileJSON(outDir, 600).imageMetadataError).toMatch(/EACCES/); + await lib.close(); + }); +}); + // Every entry under collections/, one level of directories deep, with each // symlink's target. const tree = (outDir: string): string[] => { diff --git a/test/exif-heic.ts b/test/exif-heic.ts index ad3acc6..30da019 100644 --- a/test/exif-heic.ts +++ b/test/exif-heic.ts @@ -1,7 +1,7 @@ /** * `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`. + * values as the hand-built JPEG in `exif-jpeg.ts`, for the tests of `exif()`, + * `backup-metadata --exif` and the image metadata `quak backup` records. * * It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3), * with libheif 1.23.0 and exiftool 13.55: diff --git a/test/exif-jpeg.ts b/test/exif-jpeg.ts new file mode 100644 index 0000000..433e94d --- /dev/null +++ b/test/exif-jpeg.ts @@ -0,0 +1,89 @@ +/** + * Hand-built JPEGs for the tests of `exif()` and of the image metadata + * `quak backup` records: one whose EXIF holds the same values as `exif.heic` + * (see `exif-heic.ts`), and one whose EXIF cannot be parsed. + */ + +// Big-endian bytes for the hand-built JPEG below. +const u16 = (n: number): number[] => [n >> 8, n & 0xff]; +const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)]; +const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0]; +const rational = (num: number, den: number): number[] => [ + ...u32(num), + ...u32(den), +]; +// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL), +// count, then the value when it fits in 4 bytes, else its offset. +const entry = ( + tag: number, + type: number, + count: number, + value: number[], +): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value]; + +// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed +// methods return: the camera in the first IFD, the exposure in the Exif IFD, +// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. +// Offsets count from the start of this block. +const TIFF = [ + ...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF + ...u32(8), // the first IFD's offset + // The first IFD, at 8: five entries, then no next IFD. + ...u16(5), + ...entry(0x010f, 2, 6, u32(74)), // Make + ...entry(0x0110, 2, 7, u32(80)), // Model + ...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation + ...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset + ...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset + ...u32(0), + ...ascii("Canon"), // at 74 + ...ascii("EOS R5"), // at 80 + 0, // a pad byte + // The Exif IFD, at 88: seven entries, then no next IFD. + ...u16(7), + ...entry(0x829a, 5, 1, u32(178)), // ExposureTime + ...entry(0x829d, 5, 1, u32(186)), // FNumber + ...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings + ...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal + ...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal + ...entry(0x920a, 5, 1, u32(222)), // FocalLength + ...entry(0xa434, 2, 16, u32(230)), // LensModel + ...u32(0), + ...rational(1, 250), // at 178 + ...rational(28, 10), // at 186 + ...ascii("2021:07:15 14:30:00"), // at 194 + ...ascii("+02:00"), // at 214 + 0, // a pad byte + ...rational(50, 1), // at 222 + ...ascii("RF50mm F1.8 STM"), // at 230 + // The GPS IFD, at 246: six entries, then no next IFD. + ...u16(6), + ...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef + ...entry(0x0002, 5, 3, u32(324)), // GPSLatitude + ...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef + ...entry(0x0004, 5, 3, u32(348)), // GPSLongitude + ...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level + ...entry(0x0006, 5, 1, u32(372)), // GPSAltitude + ...u32(0), + ...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324 + ...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348 + ...rational(25, 2), // at 372 +]; + +export const JPEG_WITH_EXIF = new Uint8Array([ + ...[0xff, 0xd8], // start of image + ...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length + ...[...ascii("Exif"), 0], // "Exif\0\0" + ...TIFF, + ...[0xff, 0xda, 0x00, 0x02], // start of scan +]); + +// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF +// byte order belongs, so exifreader cannot parse it. +export const JPEG_WITH_BAD_EXIF = new Uint8Array([ + ...[0xff, 0xd8], // start of image + ...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length + ...[...ascii("Exif"), 0], // "Exif\0\0" + ...[0x58, 0x58], // "XX" + ...[0xff, 0xda, 0x00, 0x02], // start of scan +]); diff --git a/test/library/content-library.test.ts b/test/library/content-library.test.ts index a8a139f..48a1094 100644 --- a/test/library/content-library.test.ts +++ b/test/library/content-library.test.ts @@ -29,6 +29,7 @@ import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { Collection, EnteFile } from "../../src/model/types.js"; import { readPhotoExif, type PhotoExif } from "../../src/exif.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js"; +import { JPEG_WITH_BAD_EXIF, JPEG_WITH_EXIF } from "../exif-jpeg.js"; import { asLivePhoto, cdnSource, @@ -256,90 +257,6 @@ describe("Library content wiring", () => { }); }); -// Big-endian bytes for the hand-built JPEG below. -const u16 = (n: number): number[] => [n >> 8, n & 0xff]; -const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)]; -const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0]; -const rational = (num: number, den: number): number[] => [ - ...u32(num), - ...u32(den), -]; -// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL), -// count, then the value when it fits in 4 bytes, else its offset. -const entry = ( - tag: number, - type: number, - count: number, - value: number[], -): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value]; - -// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed -// methods return: the camera in the first IFD, the exposure in the Exif IFD, -// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. -// Offsets count from the start of this block. -const TIFF = [ - ...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF - ...u32(8), // the first IFD's offset - // The first IFD, at 8: five entries, then no next IFD. - ...u16(5), - ...entry(0x010f, 2, 6, u32(74)), // Make - ...entry(0x0110, 2, 7, u32(80)), // Model - ...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation - ...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset - ...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset - ...u32(0), - ...ascii("Canon"), // at 74 - ...ascii("EOS R5"), // at 80 - 0, // a pad byte - // The Exif IFD, at 88: seven entries, then no next IFD. - ...u16(7), - ...entry(0x829a, 5, 1, u32(178)), // ExposureTime - ...entry(0x829d, 5, 1, u32(186)), // FNumber - ...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings - ...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal - ...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal - ...entry(0x920a, 5, 1, u32(222)), // FocalLength - ...entry(0xa434, 2, 16, u32(230)), // LensModel - ...u32(0), - ...rational(1, 250), // at 178 - ...rational(28, 10), // at 186 - ...ascii("2021:07:15 14:30:00"), // at 194 - ...ascii("+02:00"), // at 214 - 0, // a pad byte - ...rational(50, 1), // at 222 - ...ascii("RF50mm F1.8 STM"), // at 230 - // The GPS IFD, at 246: six entries, then no next IFD. - ...u16(6), - ...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef - ...entry(0x0002, 5, 3, u32(324)), // GPSLatitude - ...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef - ...entry(0x0004, 5, 3, u32(348)), // GPSLongitude - ...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level - ...entry(0x0006, 5, 1, u32(372)), // GPSAltitude - ...u32(0), - ...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324 - ...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348 - ...rational(25, 2), // at 372 -]; - -const JPEG_WITH_EXIF = new Uint8Array([ - ...[0xff, 0xd8], // start of image - ...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length - ...[...ascii("Exif"), 0], // "Exif\0\0" - ...TIFF, - ...[0xff, 0xda, 0x00, 0x02], // start of scan -]); - -// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF -// 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 - ...[...ascii("Exif"), 0], // "Exif\0\0" - ...[0x58, 0x58], // "XX" - ...[0xff, 0xda, 0x00, 0x02], // start of scan -]); - describe("Photo save path, local copy, content and EXIF", () => { // The same account, with `files` in its album instead. class FilesClient extends MockClient {