Support EXIF data in PNG files (#314)
* Use exifr data to fill in gaps from PNG files * Handle exif/exifr location data inconsistencies
This commit is contained in:
parent
a5af7c617c
commit
d13c8b0fae
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
getCompatibleExifValue,
|
||||||
convertApertureValueToFNumber,
|
convertApertureValueToFNumber,
|
||||||
getAspectRatioFromExif,
|
getAspectRatioFromExif,
|
||||||
getOffsetFromExif,
|
getOffsetFromExif,
|
||||||
@ -11,21 +12,21 @@ import { GEO_PRIVACY_ENABLED } from '@/app/config';
|
|||||||
import { PhotoExif } from '..';
|
import { PhotoExif } from '..';
|
||||||
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
||||||
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
||||||
import type { ExifData } from 'ts-exif-parser';
|
import type { ExifData, ExifTags } from 'ts-exif-parser';
|
||||||
|
|
||||||
export const convertExifToFormData = (
|
export const convertExifToFormData = (
|
||||||
data: ExifData,
|
exif: ExifData,
|
||||||
dataExifr?: any,
|
exifr?: any,
|
||||||
film?: FujifilmSimulation,
|
film?: FujifilmSimulation,
|
||||||
recipeData?: FujifilmRecipe,
|
recipeData?: FujifilmRecipe,
|
||||||
): Partial<Record<keyof PhotoExif, string | undefined>> => {
|
): Partial<Record<keyof PhotoExif, string | undefined>> => {
|
||||||
let title: string | undefined = dataExifr?.title?.value;
|
let title: string | undefined = exifr?.title?.value;
|
||||||
let caption: string | undefined;
|
let caption: string | undefined;
|
||||||
const description: string | undefined =
|
const description: string | undefined =
|
||||||
data.tags?.ImageDescription ||
|
exif.tags?.ImageDescription ||
|
||||||
dataExifr?.ImageDescription ||
|
exifr?.ImageDescription ||
|
||||||
dataExifr?.description?.value;
|
exifr?.description?.value;
|
||||||
const tags: string[] | undefined = dataExifr?.subject;
|
const tags: string[] | undefined = exifr?.subject;
|
||||||
|
|
||||||
if (title && title !== description) {
|
if (title && title !== description) {
|
||||||
caption = description;
|
caption = description;
|
||||||
@ -33,34 +34,46 @@ export const convertExifToFormData = (
|
|||||||
title = description;
|
title = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convenience function with exif + exifr in scope
|
||||||
|
const getExifValue = (
|
||||||
|
key: keyof ExifTags,
|
||||||
|
exifrSpecificKey?: string,
|
||||||
|
) => getCompatibleExifValue(key, exif, exifr, exifrSpecificKey);
|
||||||
|
|
||||||
|
const dateTimeOriginal = getExifValue('DateTimeOriginal');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
aspectRatio: getAspectRatioFromExif(data).toString(),
|
aspectRatio: getAspectRatioFromExif(exif).toString(),
|
||||||
make: data.tags?.Make,
|
make: getExifValue('Make'),
|
||||||
model: data.tags?.Model,
|
model: getExifValue('Model'),
|
||||||
focalLength: data.tags?.FocalLength?.toString(),
|
focalLength: getExifValue('FocalLength')?.toString(),
|
||||||
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
|
focalLengthIn35MmFormat:getExifValue('FocalLengthIn35mmFormat')?.toString(),
|
||||||
lensMake: data.tags?.LensMake,
|
lensMake: getExifValue('LensMake'),
|
||||||
lensModel: data.tags?.LensModel,
|
lensModel: getExifValue('LensModel'),
|
||||||
fNumber: (
|
fNumber: (
|
||||||
data.tags?.FNumber?.toString() ||
|
getExifValue('FNumber')?.toString() ||
|
||||||
convertApertureValueToFNumber(data.tags?.ApertureValue)
|
convertApertureValueToFNumber(getExifValue('ApertureValue'))
|
||||||
),
|
),
|
||||||
iso: data.tags?.ISO?.toString() || data.tags?.ISOSpeed?.toString(),
|
iso:
|
||||||
exposureTime: data.tags?.ExposureTime?.toString(),
|
getExifValue('ISO')?.toString() ||
|
||||||
exposureCompensation: data.tags?.ExposureCompensation?.toString(),
|
getExifValue('ISOSpeed')?.toString(),
|
||||||
latitude:
|
exposureTime: getExifValue('ExposureTime')?.toString(),
|
||||||
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLatitude?.toString() : undefined,
|
exposureCompensation: getExifValue('ExposureCompensation')?.toString(),
|
||||||
longitude:
|
latitude: !GEO_PRIVACY_ENABLED
|
||||||
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
|
? getExifValue('GPSLatitude', 'latitude')?.toString()
|
||||||
|
: undefined,
|
||||||
|
longitude: !GEO_PRIVACY_ENABLED
|
||||||
|
? getExifValue('GPSLongitude', 'longitude')?.toString()
|
||||||
|
: undefined,
|
||||||
film,
|
film,
|
||||||
recipeData: JSON.stringify(recipeData),
|
recipeData: JSON.stringify(recipeData),
|
||||||
...data.tags?.DateTimeOriginal && {
|
...dateTimeOriginal && {
|
||||||
takenAt: convertTimestampWithOffsetToPostgresString(
|
takenAt: convertTimestampWithOffsetToPostgresString(
|
||||||
data.tags.DateTimeOriginal,
|
dateTimeOriginal,
|
||||||
getOffsetFromExif(data),
|
getOffsetFromExif(exif, exifr),
|
||||||
),
|
),
|
||||||
takenAtNaive:
|
takenAtNaive:
|
||||||
convertTimestampToNaivePostgresString(data.tags.DateTimeOriginal),
|
convertTimestampToNaivePostgresString(dateTimeOriginal),
|
||||||
},
|
},
|
||||||
...title && { title },
|
...title && { title },
|
||||||
...caption && { caption },
|
...caption && { caption },
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { PhotoDbInsert } from '.';
|
|||||||
import { convertExifToFormData } from './form/server';
|
import { convertExifToFormData } from './form/server';
|
||||||
import { getColorFieldsForPhotoForm } from './color/server';
|
import { getColorFieldsForPhotoForm } from './color/server';
|
||||||
import exifr from 'exifr';
|
import exifr from 'exifr';
|
||||||
|
import { getCompatibleExifValue } from '@/utility/exif';
|
||||||
|
|
||||||
const IMAGE_WIDTH_BLUR = 200;
|
const IMAGE_WIDTH_BLUR = 200;
|
||||||
const IMAGE_WIDTH_DEFAULT = 200;
|
const IMAGE_WIDTH_DEFAULT = 200;
|
||||||
@ -60,8 +61,8 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
fileId: blobId,
|
fileId: blobId,
|
||||||
} = getFileNamePartsFromStorageUrl(url);
|
} = getFileNamePartsFromStorageUrl(url);
|
||||||
|
|
||||||
let exifData: ExifData | undefined;
|
let dataExif: ExifData | undefined;
|
||||||
let exifrData: any | undefined;
|
let dataExifr: any | undefined;
|
||||||
let film: FujifilmSimulation | undefined;
|
let film: FujifilmSimulation | undefined;
|
||||||
let recipe: FujifilmRecipe | undefined;
|
let recipe: FujifilmRecipe | undefined;
|
||||||
let blurData: string | undefined;
|
let blurData: string | undefined;
|
||||||
@ -83,11 +84,11 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
|
|
||||||
// Data for form
|
// Data for form
|
||||||
parser.enableBinaryFields(false);
|
parser.enableBinaryFields(false);
|
||||||
exifData = parser.parse();
|
dataExif = parser.parse();
|
||||||
exifrData = await exifr.parse(fileBytes, { xmp: true });
|
dataExifr = await exifr.parse(fileBytes, { xmp: true });
|
||||||
|
|
||||||
// Capture film simulation for Fujifilm cameras
|
// Capture film simulation for Fujifilm cameras
|
||||||
if (isExifForFujifilm(exifData)) {
|
if (isExifForFujifilm(dataExif)) {
|
||||||
// Parse exif data again with binary fields
|
// Parse exif data again with binary fields
|
||||||
// in order to access MakerNote tag
|
// in order to access MakerNote tag
|
||||||
parser.enableBinaryFields(true);
|
parser.enableBinaryFields(true);
|
||||||
@ -108,8 +109,8 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
|
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
|
||||||
Boolean(exifData.tags?.GPSLatitude) ||
|
Boolean(getCompatibleExifValue('GPSLatitude', dataExif, dataExifr)) ||
|
||||||
Boolean(exifData.tags?.GPSLongitude)
|
Boolean(getCompatibleExifValue('GPSLongitude', dataExif, dataExifr))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -124,7 +125,7 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
blobId,
|
blobId,
|
||||||
...exifData && {
|
...dataExif && {
|
||||||
formDataFromExif: {
|
formDataFromExif: {
|
||||||
...includeInitialPhotoFields && {
|
...includeInitialPhotoFields && {
|
||||||
hidden: 'false',
|
hidden: 'false',
|
||||||
@ -133,7 +134,7 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
url,
|
url,
|
||||||
},
|
},
|
||||||
...generateBlurData && { blurData },
|
...generateBlurData && { blurData },
|
||||||
...convertExifToFormData(exifData, exifrData, film, recipe),
|
...convertExifToFormData(dataExif, dataExifr, film, recipe),
|
||||||
...colorFields,
|
...colorFields,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { parseISO, parse, format } from 'date-fns';
|
import { parseISO, parse, format, isDate } from 'date-fns';
|
||||||
import { formatInTimeZone } from 'date-fns-tz';
|
import { formatInTimeZone } from 'date-fns-tz';
|
||||||
import { Timezone } from './timezone';
|
import { Timezone } from './timezone';
|
||||||
import { setDefaultDateFnLocale } from '@/i18n';
|
import { setDefaultDateFnLocale } from '@/i18n';
|
||||||
@ -101,7 +101,10 @@ const dateFromTimestamp = (timestamp?: AmbiguousTimestamp): Date => {
|
|||||||
? /.+Z/i.test(timestamp)
|
? /.+Z/i.test(timestamp)
|
||||||
? new Date(timestamp)
|
? new Date(timestamp)
|
||||||
: new Date(`${timestamp}Z`)
|
: new Date(`${timestamp}Z`)
|
||||||
: undefined;
|
// Check for date last to avoid destabilizing status quo
|
||||||
|
: isDate(timestamp)
|
||||||
|
? timestamp
|
||||||
|
: undefined;
|
||||||
return date && !isNaN(date.getTime()) ? date : new Date();
|
return date && !isNaN(date.getTime()) ? date : new Date();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,25 @@
|
|||||||
import { OrientationTypes, type ExifData } from 'ts-exif-parser';
|
import { OrientationTypes, type ExifData, ExifTags } from 'ts-exif-parser';
|
||||||
|
|
||||||
const OFFSET_REGEX = /[+-]\d\d:\d\d/;
|
const OFFSET_REGEX = /[+-]\d\d:\d\d/;
|
||||||
|
|
||||||
export const getOffsetFromExif = (data: ExifData) =>
|
export const getCompatibleExifValue = (
|
||||||
Object.values(data.tags as any)
|
key: keyof ExifTags,
|
||||||
.find((value: any) =>
|
exif: ExifData,
|
||||||
typeof value === 'string' &&
|
exifr: any,
|
||||||
OFFSET_REGEX.test(value),
|
exifrSpecificKey?: string,
|
||||||
) as string | undefined;
|
) => exif.tags?.[key] || exifr?.[exifrSpecificKey || key];
|
||||||
|
|
||||||
|
const isValueOffset = (value: any) =>
|
||||||
|
typeof value === 'string' &&
|
||||||
|
OFFSET_REGEX.test(value);
|
||||||
|
|
||||||
|
export const getOffsetFromExif = (
|
||||||
|
exif: ExifData,
|
||||||
|
exifr: any,
|
||||||
|
) => (
|
||||||
|
Object.values(exif.tags as any).find(isValueOffset) ||
|
||||||
|
Object.values(exifr).find(isValueOffset)
|
||||||
|
) as string | undefined;
|
||||||
|
|
||||||
export const getAspectRatioFromExif = (data: ExifData): number => {
|
export const getAspectRatioFromExif = (data: ExifData): number => {
|
||||||
// Using '||' operator to handle `Orientation` unexpectedly being '0'
|
// Using '||' operator to handle `Orientation` unexpectedly being '0'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user