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:
Sam Becker 2025-09-12 21:11:36 -05:00 committed by GitHub
parent a5af7c617c
commit d13c8b0fae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 75 additions and 46 deletions

View File

@ -1,4 +1,5 @@
import {
getCompatibleExifValue,
convertApertureValueToFNumber,
getAspectRatioFromExif,
getOffsetFromExif,
@ -11,21 +12,21 @@ import { GEO_PRIVACY_ENABLED } from '@/app/config';
import { PhotoExif } from '..';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import type { ExifData } from 'ts-exif-parser';
import type { ExifData, ExifTags } from 'ts-exif-parser';
export const convertExifToFormData = (
data: ExifData,
dataExifr?: any,
exif: ExifData,
exifr?: any,
film?: FujifilmSimulation,
recipeData?: FujifilmRecipe,
): Partial<Record<keyof PhotoExif, string | undefined>> => {
let title: string | undefined = dataExifr?.title?.value;
let title: string | undefined = exifr?.title?.value;
let caption: string | undefined;
const description: string | undefined =
data.tags?.ImageDescription ||
dataExifr?.ImageDescription ||
dataExifr?.description?.value;
const tags: string[] | undefined = dataExifr?.subject;
exif.tags?.ImageDescription ||
exifr?.ImageDescription ||
exifr?.description?.value;
const tags: string[] | undefined = exifr?.subject;
if (title && title !== description) {
caption = description;
@ -33,34 +34,46 @@ export const convertExifToFormData = (
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 {
aspectRatio: getAspectRatioFromExif(data).toString(),
make: data.tags?.Make,
model: data.tags?.Model,
focalLength: data.tags?.FocalLength?.toString(),
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
lensMake: data.tags?.LensMake,
lensModel: data.tags?.LensModel,
aspectRatio: getAspectRatioFromExif(exif).toString(),
make: getExifValue('Make'),
model: getExifValue('Model'),
focalLength: getExifValue('FocalLength')?.toString(),
focalLengthIn35MmFormat:getExifValue('FocalLengthIn35mmFormat')?.toString(),
lensMake: getExifValue('LensMake'),
lensModel: getExifValue('LensModel'),
fNumber: (
data.tags?.FNumber?.toString() ||
convertApertureValueToFNumber(data.tags?.ApertureValue)
getExifValue('FNumber')?.toString() ||
convertApertureValueToFNumber(getExifValue('ApertureValue'))
),
iso: data.tags?.ISO?.toString() || data.tags?.ISOSpeed?.toString(),
exposureTime: data.tags?.ExposureTime?.toString(),
exposureCompensation: data.tags?.ExposureCompensation?.toString(),
latitude:
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLatitude?.toString() : undefined,
longitude:
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
iso:
getExifValue('ISO')?.toString() ||
getExifValue('ISOSpeed')?.toString(),
exposureTime: getExifValue('ExposureTime')?.toString(),
exposureCompensation: getExifValue('ExposureCompensation')?.toString(),
latitude: !GEO_PRIVACY_ENABLED
? getExifValue('GPSLatitude', 'latitude')?.toString()
: undefined,
longitude: !GEO_PRIVACY_ENABLED
? getExifValue('GPSLongitude', 'longitude')?.toString()
: undefined,
film,
recipeData: JSON.stringify(recipeData),
...data.tags?.DateTimeOriginal && {
...dateTimeOriginal && {
takenAt: convertTimestampWithOffsetToPostgresString(
data.tags.DateTimeOriginal,
getOffsetFromExif(data),
dateTimeOriginal,
getOffsetFromExif(exif, exifr),
),
takenAtNaive:
convertTimestampToNaivePostgresString(data.tags.DateTimeOriginal),
convertTimestampToNaivePostgresString(dateTimeOriginal),
},
...title && { title },
...caption && { caption },

View File

@ -28,6 +28,7 @@ import { PhotoDbInsert } from '.';
import { convertExifToFormData } from './form/server';
import { getColorFieldsForPhotoForm } from './color/server';
import exifr from 'exifr';
import { getCompatibleExifValue } from '@/utility/exif';
const IMAGE_WIDTH_BLUR = 200;
const IMAGE_WIDTH_DEFAULT = 200;
@ -60,8 +61,8 @@ export const extractImageDataFromBlobPath = async (
fileId: blobId,
} = getFileNamePartsFromStorageUrl(url);
let exifData: ExifData | undefined;
let exifrData: any | undefined;
let dataExif: ExifData | undefined;
let dataExifr: any | undefined;
let film: FujifilmSimulation | undefined;
let recipe: FujifilmRecipe | undefined;
let blurData: string | undefined;
@ -83,11 +84,11 @@ export const extractImageDataFromBlobPath = async (
// Data for form
parser.enableBinaryFields(false);
exifData = parser.parse();
exifrData = await exifr.parse(fileBytes, { xmp: true });
dataExif = parser.parse();
dataExifr = await exifr.parse(fileBytes, { xmp: true });
// Capture film simulation for Fujifilm cameras
if (isExifForFujifilm(exifData)) {
if (isExifForFujifilm(dataExif)) {
// Parse exif data again with binary fields
// in order to access MakerNote tag
parser.enableBinaryFields(true);
@ -108,8 +109,8 @@ export const extractImageDataFromBlobPath = async (
}
shouldStripGpsData = GEO_PRIVACY_ENABLED && (
Boolean(exifData.tags?.GPSLatitude) ||
Boolean(exifData.tags?.GPSLongitude)
Boolean(getCompatibleExifValue('GPSLatitude', dataExif, dataExifr)) ||
Boolean(getCompatibleExifValue('GPSLongitude', dataExif, dataExifr))
);
}
} catch (e) {
@ -124,7 +125,7 @@ export const extractImageDataFromBlobPath = async (
return {
blobId,
...exifData && {
...dataExif && {
formDataFromExif: {
...includeInitialPhotoFields && {
hidden: 'false',
@ -133,7 +134,7 @@ export const extractImageDataFromBlobPath = async (
url,
},
...generateBlurData && { blurData },
...convertExifToFormData(exifData, exifrData, film, recipe),
...convertExifToFormData(dataExif, dataExifr, film, recipe),
...colorFields,
},
},

View File

@ -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 { Timezone } from './timezone';
import { setDefaultDateFnLocale } from '@/i18n';
@ -101,7 +101,10 @@ const dateFromTimestamp = (timestamp?: AmbiguousTimestamp): Date => {
? /.+Z/i.test(timestamp)
? new Date(timestamp)
: 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();
};

View File

@ -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/;
export const getOffsetFromExif = (data: ExifData) =>
Object.values(data.tags as any)
.find((value: any) =>
typeof value === 'string' &&
OFFSET_REGEX.test(value),
) as string | undefined;
export const getCompatibleExifValue = (
key: keyof ExifTags,
exif: ExifData,
exifr: any,
exifrSpecificKey?: string,
) => 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 => {
// Using '||' operator to handle `Orientation` unexpectedly being '0'