memos/web/tests/media-metadata-exif-fixture.test.ts

141 lines
5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mediaMetadataService } from "@/components/MemoEditor/services/mediaMetadataService";
describe("mediaMetadataService EXIF fixture", () => {
beforeEach(() => {
vi.stubGlobal(
"createImageBitmap",
vi.fn(async () => ({ width: 2, height: 1, close: vi.fn() })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("maps selected fields from a real JPEG EXIF payload", async () => {
const metadata = await mediaMetadataService.extract(buildExifJpegFixture());
expect(metadata?.width).toBe(3000);
expect(metadata?.height).toBe(4000);
expect(metadata?.details.case).toBe("photo");
if (metadata?.details.case !== "photo") throw new Error("expected photo metadata");
expect(metadata.details.value.captureTime).toMatchObject({
localDateTime: "2026-08-10T14:32:18.123",
utcOffset: "+08:00",
});
expect(metadata.details.value.location).toMatchObject({
latitude: 1.3521,
longitude: 103.8198,
altitudeMeters: 18.4,
});
expect(metadata.details.value).toMatchObject({
sourceExifOrientation: 6,
cameraMake: "Apple",
cameraModel: "iPhone",
lensModel: "Main Camera",
fNumber: 1.78,
exposureTimeSeconds: 1 / 120,
iso: 64,
focalLengthMm: 6.86,
});
});
});
// A minimal JPEG containing a little-endian TIFF EXIF segment with known values.
function buildExifJpegFixture(): File {
const tiff = new Uint8Array(424);
const view = new DataView(tiff.buffer);
const littleEndian = true;
tiff.set([0x49, 0x49], 0);
view.setUint16(2, 42, littleEndian);
view.setUint32(4, 8, littleEndian);
const writeEntry = (position: number, tag: number, type: number, count: number, value: number) => {
view.setUint16(position, tag, littleEndian);
view.setUint16(position + 2, type, littleEndian);
view.setUint32(position + 4, count, littleEndian);
view.setUint32(position + 8, value, littleEndian);
};
const writeShortEntry = (position: number, tag: number, value: number) => {
writeEntry(position, tag, 3, 1, 0);
view.setUint16(position + 8, value, littleEndian);
};
const writeByteEntry = (position: number, tag: number, value: number) => {
writeEntry(position, tag, 1, 1, 0);
view.setUint8(position + 8, value);
};
const writeInlineASCIIEntry = (position: number, tag: number, value: string) => {
const bytes = new TextEncoder().encode(`${value}\0`);
writeEntry(position, tag, 2, bytes.length, 0);
tiff.set(bytes, position + 8);
};
const writeASCII = (offset: number, value: string) => {
tiff.set(new TextEncoder().encode(`${value}\0`), offset);
};
const writeRational = (offset: number, numerator: number, denominator: number) => {
view.setUint32(offset, numerator, littleEndian);
view.setUint32(offset + 4, denominator, littleEndian);
};
// IFD0: camera identity, orientation, and pointers to the EXIF/GPS IFDs.
view.setUint16(8, 5, littleEndian);
writeEntry(10, 0x010f, 2, 6, 74);
writeEntry(22, 0x0110, 2, 7, 80);
writeShortEntry(34, 0x0112, 6);
writeEntry(46, 0x8769, 4, 1, 88);
writeEntry(58, 0x8825, 4, 1, 278);
view.setUint32(70, 0, littleEndian);
writeASCII(74, "Apple");
writeASCII(80, "iPhone");
// EXIF IFD: dimensions, capture time, and exposure details.
view.setUint16(88, 10, littleEndian);
writeEntry(90, 0x829a, 5, 1, 250);
writeEntry(102, 0x829d, 5, 1, 242);
writeShortEntry(114, 0x8827, 64);
writeEntry(126, 0x9003, 2, 20, 214);
writeEntry(138, 0x9011, 2, 7, 234);
writeEntry(150, 0x920a, 5, 1, 258);
writeInlineASCIIEntry(162, 0x9291, "123");
writeEntry(174, 0xa002, 4, 1, 4000);
writeEntry(186, 0xa003, 4, 1, 3000);
writeEntry(198, 0xa434, 2, 12, 266);
view.setUint32(210, 0, littleEndian);
writeASCII(214, "2026:08:10 14:32:18");
writeASCII(234, "+08:00");
writeRational(242, 178, 100);
writeRational(250, 1, 120);
writeRational(258, 686, 100);
writeASCII(266, "Main Camera");
// GPS IFD: Singapore coordinates and altitude.
view.setUint16(278, 7, littleEndian);
writeEntry(280, 0x0000, 1, 4, 0x00000302);
writeInlineASCIIEntry(292, 0x0001, "N");
writeEntry(304, 0x0002, 5, 3, 368);
writeInlineASCIIEntry(316, 0x0003, "E");
writeEntry(328, 0x0004, 5, 3, 392);
writeByteEntry(340, 0x0005, 0);
writeEntry(352, 0x0006, 5, 1, 416);
view.setUint32(364, 0, littleEndian);
writeRational(368, 1, 1);
writeRational(376, 21, 1);
writeRational(384, 756, 100);
writeRational(392, 103, 1);
writeRational(400, 49, 1);
writeRational(408, 1128, 100);
writeRational(416, 184, 10);
const exifPayload = new Uint8Array(6 + tiff.length);
exifPayload.set(new TextEncoder().encode("Exif\0\0"));
exifPayload.set(tiff, 6);
const jpeg = new Uint8Array(2 + 2 + 2 + exifPayload.length + 2);
jpeg.set([0xff, 0xd8, 0xff, 0xe1], 0);
new DataView(jpeg.buffer).setUint16(4, exifPayload.length + 2);
jpeg.set(exifPayload, 6);
jpeg.set([0xff, 0xd9], jpeg.length - 2);
return new File([jpeg], "known-exif.jpg", { type: "image/jpeg" });
}