From d13c8b0fae43a2ee636d96c94b334bfd37c8e4b5 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Fri, 12 Sep 2025 21:11:36 -0500 Subject: [PATCH] Support EXIF data in PNG files (#314) * Use exifr data to fill in gaps from PNG files * Handle exif/exifr location data inconsistencies --- src/photo/form/server.ts | 69 ++++++++++++++++++++++++---------------- src/photo/server.ts | 19 +++++------ src/utility/date.ts | 7 ++-- src/utility/exif.ts | 26 +++++++++++---- 4 files changed, 75 insertions(+), 46 deletions(-) diff --git a/src/photo/form/server.ts b/src/photo/form/server.ts index 5d034716..251fa731 100644 --- a/src/photo/form/server.ts +++ b/src/photo/form/server.ts @@ -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> => { - 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 }, diff --git a/src/photo/server.ts b/src/photo/server.ts index 7ab9b016..aedd7495 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -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, }, }, diff --git a/src/utility/date.ts b/src/utility/date.ts index b27afd23..636bc506 100644 --- a/src/utility/date.ts +++ b/src/utility/date.ts @@ -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(); }; diff --git a/src/utility/exif.ts b/src/utility/exif.ts index ff3de60d..8fa6b4d5 100644 --- a/src/utility/exif.ts +++ b/src/utility/exif.ts @@ -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'