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 { 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 },

View File

@ -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,
}, },
}, },

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 { 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();
}; };

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/; 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'