mirror of
https://github.com/ente-io/ente.git
synced 2025-08-07 23:18:10 +00:00
336 lines
9.6 KiB
TypeScript
336 lines
9.6 KiB
TypeScript
import log from "@/base/log";
|
|
import { type FileTypeInfo } from "@/media/file-type";
|
|
import { NULL_LOCATION } from "@/new/photos/services/upload/types";
|
|
import type {
|
|
Location,
|
|
ParsedExtractedMetadata,
|
|
} from "@/new/photos/types/metadata";
|
|
import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time";
|
|
import exifr from "exifr";
|
|
|
|
type ParsedEXIFData = Record<string, any> &
|
|
Partial<{
|
|
DateTimeOriginal: Date;
|
|
CreateDate: Date;
|
|
ModifyDate: Date;
|
|
DateCreated: Date;
|
|
MetadataDate: Date;
|
|
latitude: number;
|
|
longitude: number;
|
|
imageWidth: number;
|
|
imageHeight: number;
|
|
}>;
|
|
|
|
type RawEXIFData = Record<string, any> &
|
|
Partial<{
|
|
DateTimeOriginal: string;
|
|
CreateDate: string;
|
|
ModifyDate: string;
|
|
DateCreated: string;
|
|
MetadataDate: string;
|
|
GPSLatitude: number[];
|
|
GPSLongitude: number[];
|
|
GPSLatitudeRef: string;
|
|
GPSLongitudeRef: string;
|
|
ImageWidth: number;
|
|
ImageHeight: number;
|
|
}>;
|
|
|
|
const exifTagsNeededForParsingImageMetadata = [
|
|
"DateTimeOriginal",
|
|
"CreateDate",
|
|
"ModifyDate",
|
|
"GPSLatitude",
|
|
"GPSLongitude",
|
|
"GPSLatitudeRef",
|
|
"GPSLongitudeRef",
|
|
"DateCreated",
|
|
"ExifImageWidth",
|
|
"ExifImageHeight",
|
|
"ImageWidth",
|
|
"ImageHeight",
|
|
"PixelXDimension",
|
|
"PixelYDimension",
|
|
"MetadataDate",
|
|
];
|
|
|
|
/**
|
|
* Read Exif data from an image {@link file} and use that to construct and
|
|
* return an {@link ParsedExtractedMetadata}.
|
|
*
|
|
* This function is tailored for use when we upload files.
|
|
*/
|
|
export const parseImageMetadata = async (
|
|
file: File,
|
|
fileTypeInfo: FileTypeInfo,
|
|
): Promise<ParsedExtractedMetadata> => {
|
|
const exifData = await getParsedExifData(
|
|
file,
|
|
fileTypeInfo,
|
|
exifTagsNeededForParsingImageMetadata,
|
|
);
|
|
|
|
// TODO: Exif- remove me.
|
|
log.debug(() => ["exif/old", exifData]);
|
|
return {
|
|
location: getEXIFLocation(exifData),
|
|
creationTime: getEXIFTime(exifData),
|
|
width: exifData?.imageWidth ?? null,
|
|
height: exifData?.imageHeight ?? null,
|
|
};
|
|
};
|
|
|
|
export async function getParsedExifData(
|
|
receivedFile: File,
|
|
{ extension }: FileTypeInfo,
|
|
tags?: string[],
|
|
): Promise<ParsedEXIFData> {
|
|
const exifLessFormats = ["gif", "bmp"];
|
|
const exifrUnsupportedFileFormatMessage = "Unknown file format";
|
|
|
|
try {
|
|
if (exifLessFormats.includes(extension)) return null;
|
|
|
|
const exifData: RawEXIFData = await exifr.parse(receivedFile, {
|
|
reviveValues: false,
|
|
tiff: true,
|
|
xmp: true,
|
|
icc: true,
|
|
iptc: true,
|
|
jfif: true,
|
|
ihdr: true,
|
|
});
|
|
if (!exifData) {
|
|
return null;
|
|
}
|
|
const filteredExifData = tags
|
|
? Object.fromEntries(
|
|
Object.entries(exifData).filter(([key]) =>
|
|
tags.includes(key),
|
|
),
|
|
)
|
|
: exifData;
|
|
return parseExifData(filteredExifData);
|
|
} catch (e) {
|
|
if (e.message == exifrUnsupportedFileFormatMessage) {
|
|
log.error(`EXIFR does not support ${extension} files`, e);
|
|
return undefined;
|
|
} else {
|
|
log.error(`Failed to parse Exif data for a ${extension} file`, e);
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseExifData(exifData: RawEXIFData): ParsedEXIFData {
|
|
if (!exifData) {
|
|
return null;
|
|
}
|
|
const {
|
|
DateTimeOriginal,
|
|
CreateDate,
|
|
ModifyDate,
|
|
DateCreated,
|
|
ImageHeight,
|
|
ImageWidth,
|
|
ExifImageHeight,
|
|
ExifImageWidth,
|
|
PixelXDimension,
|
|
PixelYDimension,
|
|
MetadataDate,
|
|
...rest
|
|
} = exifData;
|
|
const parsedExif: ParsedEXIFData = { ...rest };
|
|
if (DateTimeOriginal) {
|
|
parsedExif.DateTimeOriginal = parseEXIFDate(exifData.DateTimeOriginal);
|
|
}
|
|
if (CreateDate) {
|
|
parsedExif.CreateDate = parseEXIFDate(exifData.CreateDate);
|
|
}
|
|
if (ModifyDate) {
|
|
parsedExif.ModifyDate = parseEXIFDate(exifData.ModifyDate);
|
|
}
|
|
if (DateCreated) {
|
|
parsedExif.DateCreated = parseEXIFDate(exifData.DateCreated);
|
|
}
|
|
if (MetadataDate) {
|
|
parsedExif.MetadataDate = parseEXIFDate(exifData.MetadataDate);
|
|
}
|
|
if (exifData.GPSLatitude && exifData.GPSLongitude) {
|
|
const parsedLocation = parseEXIFLocation(
|
|
exifData.GPSLatitude,
|
|
exifData.GPSLatitudeRef,
|
|
exifData.GPSLongitude,
|
|
exifData.GPSLongitudeRef,
|
|
);
|
|
parsedExif.latitude = parsedLocation.latitude;
|
|
parsedExif.longitude = parsedLocation.longitude;
|
|
}
|
|
if (ImageWidth && ImageHeight) {
|
|
if (typeof ImageWidth === "number" && typeof ImageHeight === "number") {
|
|
parsedExif.imageWidth = ImageWidth;
|
|
parsedExif.imageHeight = ImageHeight;
|
|
} else {
|
|
log.warn("Exif: Ignoring non-numeric ImageWidth or ImageHeight");
|
|
}
|
|
} else if (ExifImageWidth && ExifImageHeight) {
|
|
if (
|
|
typeof ExifImageWidth === "number" &&
|
|
typeof ExifImageHeight === "number"
|
|
) {
|
|
parsedExif.imageWidth = ExifImageWidth;
|
|
parsedExif.imageHeight = ExifImageHeight;
|
|
} else {
|
|
log.warn(
|
|
"Exif: Ignoring non-numeric ExifImageWidth or ExifImageHeight",
|
|
);
|
|
}
|
|
} else if (PixelXDimension && PixelYDimension) {
|
|
if (
|
|
typeof PixelXDimension === "number" &&
|
|
typeof PixelYDimension === "number"
|
|
) {
|
|
parsedExif.imageWidth = PixelXDimension;
|
|
parsedExif.imageHeight = PixelYDimension;
|
|
} else {
|
|
log.warn(
|
|
"Exif: Ignoring non-numeric PixelXDimension or PixelYDimension",
|
|
);
|
|
}
|
|
}
|
|
return parsedExif;
|
|
}
|
|
|
|
function parseEXIFDate(dateTimeString: string) {
|
|
try {
|
|
if (typeof dateTimeString !== "string" || dateTimeString === "") {
|
|
throw new Error("Invalid date string");
|
|
}
|
|
|
|
// Check and parse date in the format YYYYMMDD
|
|
if (dateTimeString.length === 8) {
|
|
const year = Number(dateTimeString.slice(0, 4));
|
|
const month = Number(dateTimeString.slice(4, 6));
|
|
const day = Number(dateTimeString.slice(6, 8));
|
|
if (
|
|
!Number.isNaN(year) &&
|
|
!Number.isNaN(month) &&
|
|
!Number.isNaN(day)
|
|
) {
|
|
const date = new Date(year, month - 1, day);
|
|
if (!Number.isNaN(+date)) {
|
|
return date;
|
|
}
|
|
}
|
|
}
|
|
const [year, month, day, hour, minute, second] = dateTimeString
|
|
.match(/\d+/g)
|
|
.map(Number);
|
|
|
|
if (
|
|
typeof year === "undefined" ||
|
|
Number.isNaN(year) ||
|
|
typeof month === "undefined" ||
|
|
Number.isNaN(month) ||
|
|
typeof day === "undefined" ||
|
|
Number.isNaN(day)
|
|
) {
|
|
throw new Error("Invalid date");
|
|
}
|
|
let date: Date;
|
|
if (
|
|
typeof hour === "undefined" ||
|
|
Number.isNaN(hour) ||
|
|
typeof minute === "undefined" ||
|
|
Number.isNaN(minute) ||
|
|
typeof second === "undefined" ||
|
|
Number.isNaN(second)
|
|
) {
|
|
date = new Date(year, month - 1, day);
|
|
} else {
|
|
date = new Date(year, month - 1, day, hour, minute, second);
|
|
}
|
|
if (Number.isNaN(+date)) {
|
|
throw new Error("Invalid date");
|
|
}
|
|
return date;
|
|
} catch (e) {
|
|
log.error(`Failed to parseEXIFDate ${dateTimeString}`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function parseEXIFLocation(
|
|
gpsLatitude: number[],
|
|
gpsLatitudeRef: string,
|
|
gpsLongitude: number[],
|
|
gpsLongitudeRef: string,
|
|
) {
|
|
try {
|
|
if (
|
|
!Array.isArray(gpsLatitude) ||
|
|
!Array.isArray(gpsLongitude) ||
|
|
gpsLatitude.length !== 3 ||
|
|
gpsLongitude.length !== 3
|
|
) {
|
|
throw new Error("Invalid Exif location");
|
|
}
|
|
const latitude = convertDMSToDD(
|
|
gpsLatitude[0],
|
|
gpsLatitude[1],
|
|
gpsLatitude[2],
|
|
gpsLatitudeRef,
|
|
);
|
|
const longitude = convertDMSToDD(
|
|
gpsLongitude[0],
|
|
gpsLongitude[1],
|
|
gpsLongitude[2],
|
|
gpsLongitudeRef,
|
|
);
|
|
return { latitude, longitude };
|
|
} catch (e) {
|
|
const p = {
|
|
gpsLatitude,
|
|
gpsLatitudeRef,
|
|
gpsLongitude,
|
|
gpsLongitudeRef,
|
|
};
|
|
log.error(`Failed to parse Exif location ${JSON.stringify(p)}`, e);
|
|
return { ...NULL_LOCATION };
|
|
}
|
|
}
|
|
|
|
function convertDMSToDD(
|
|
degrees: number,
|
|
minutes: number,
|
|
seconds: number,
|
|
direction: string,
|
|
) {
|
|
let dd = degrees + minutes / 60 + seconds / (60 * 60);
|
|
if (direction === "S" || direction === "W") dd *= -1;
|
|
return dd;
|
|
}
|
|
|
|
export function getEXIFLocation(exifData: ParsedEXIFData): Location {
|
|
if (!exifData || (!exifData.latitude && exifData.latitude !== 0)) {
|
|
return { ...NULL_LOCATION };
|
|
}
|
|
return { latitude: exifData.latitude, longitude: exifData.longitude };
|
|
}
|
|
|
|
export function getEXIFTime(exifData: ParsedEXIFData): number {
|
|
if (!exifData) {
|
|
return null;
|
|
}
|
|
const dateTime =
|
|
exifData.DateTimeOriginal ??
|
|
exifData.DateCreated ??
|
|
exifData.CreateDate ??
|
|
exifData.MetadataDate ??
|
|
exifData.ModifyDate;
|
|
if (!dateTime) {
|
|
return null;
|
|
}
|
|
return validateAndGetCreationUnixTimeInMicroSeconds(dateTime);
|
|
}
|