Gracefully handle missing EXIF data

This commit is contained in:
Sam Becker 2023-10-20 18:19:11 -05:00
parent a0fd1da8c8
commit 068a0638a0
6 changed files with 105 additions and 52 deletions

View File

@ -1,6 +1,6 @@
'use client';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
FORM_METADATA_ENTRIES,
PhotoFormData,
@ -13,6 +13,10 @@ import Link from 'next/link';
import { cc } from '@/utility/css';
import CanvasBlurCapture from '@/components/CanvasBlurCapture';
import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import {
generateLocalNaivePostgresString,
generateLocalPostgresString,
} from '@/utility/date';
const THUMBNAIL_WIDTH = 300;
const THUMBNAIL_HEIGHT = 200;
@ -29,6 +33,22 @@ export default function PhotoForm({
const [formData, setFormData] =
useState<Partial<PhotoFormData>>(initialPhotoForm);
// Generate local date strings when
// none can be harvested from EXIF
useEffect(() => {
if (!formData.takenAt || !formData.takenAtNaive) {
setFormData(data => ({
...data,
...!formData.takenAt && {
takenAt: generateLocalPostgresString(),
},
...!formData.takenAtNaive && {
takenAtNaive: generateLocalNaivePostgresString(),
},
}));
}
}, [formData.takenAt, formData.takenAtNaive]);
const url = formData.url ?? '';
const updateBlurData = useCallback((blurData: string) => {

View File

@ -1,4 +1,4 @@
import { Photo, titleForPhoto } from '.';
import { Photo, photoHasCameraData, photoHasExifData, titleForPhoto } from '.';
import SiteGrid from '@/components/SiteGrid';
import ImageLarge from '@/components/ImageLarge';
import { cc } from '@/utility/css';
@ -72,7 +72,7 @@ export default function PhotoLarge({
{tagsToShow.length > 0 &&
<PhotoTags tags={tagsToShow} />}
</div>
{showCamera &&
{showCamera && photoHasCameraData(photo) &&
<PhotoCamera
camera={camera}
showIcon={false}
@ -80,25 +80,29 @@ export default function PhotoLarge({
/>}
</>)}
{renderMiniGrid(<>
<ul className={cc(
'text-gray-500',
'dark:text-gray-400',
)}>
<li>
{photo.focalLengthFormatted}
{' '}
<span className={cc(
'text-gray-400/80',
'dark:text-gray-400/50',
)}>
{photo.focalLengthIn35MmFormatFormatted}
</span>
</li>
<li>{photo.fNumberFormatted}</li>
<li>{photo.isoFormatted}</li>
<li>{photo.exposureTimeFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '—'}</li>
</ul>
{photoHasExifData(photo) &&
<ul className={cc(
'text-gray-500',
'dark:text-gray-400',
)}>
<li>
{photo.focalLengthFormatted}
{photo.focalLengthIn35MmFormatFormatted &&
<>
{' '}
<span className={cc(
'text-gray-400/80',
'dark:text-gray-400/50',
)}>
{photo.focalLengthIn35MmFormatFormatted}
</span>
</>}
</li>
<li>{photo.fNumberFormatted}</li>
<li>{photo.isoFormatted}</li>
<li>{photo.exposureTimeFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '—'}</li>
</ul>}
<div className={cc(
'flex gap-y-4',
'flex-col sm:flex-row md:flex-col',

View File

@ -94,13 +94,15 @@ export const convertExifToFormData = (
latitude: data.tags?.GPSLatitude?.toString(),
longitude: data.tags?.GPSLongitude?.toString(),
filmSimulation: undefined,
takenAt: convertTimestampWithOffsetToPostgresString(
data.tags?.DateTimeOriginal,
getOffsetFromExif(data),
),
takenAtNaive: convertTimestampToNaivePostgresString(
data.tags?.DateTimeOriginal,
),
takenAt: data.tags?.DateTimeOriginal
? convertTimestampWithOffsetToPostgresString(
data.tags?.DateTimeOriginal,
getOffsetFromExif(data),
)
: undefined,
takenAtNaive: data.tags?.DateTimeOriginal
? convertTimestampToNaivePostgresString(data.tags?.DateTimeOriginal)
: undefined,
});
export const convertFormDataToPhoto = (
@ -109,9 +111,14 @@ export const convertFormDataToPhoto = (
): PhotoDbInsert => {
const photoForm = Object.fromEntries(formData) as PhotoFormData;
// Remove Server Action ID
// Parse FormData:
// - remove server action ID
// - remove empty strings
Object.keys(photoForm).forEach(key => {
if (key.startsWith('$ACTION_ID_')) {
if (
key.startsWith('$ACTION_ID_') ||
(photoForm as any)[key] === ''
) {
delete (photoForm as any)[key];
}
});

View File

@ -201,3 +201,15 @@ export const dateRangeForPhotos = (
: `${start}${end}`;
return { start, end, description };
};
export const photoHasCameraData = (photo: Photo) =>
photo.make ||
photo.model;
export const photoHasExifData = (photo: Photo) =>
photo.focalLength ||
photo.focalLengthIn35MmFormat ||
photo.fNumberFormatted ||
photo.isoFormatted ||
photo.exposureTimeFormatted ||
photo.exposureCompensationFormatted;

View File

@ -16,24 +16,34 @@ export const formatDateForPostgres = (date: Date) =>
'$1-$2-$3 $4',
);
const createNaiveDateWithOffset = (
dateTimestamp = 0,
offset = '+00:00',
) => {
const date = new Date(dateTimestamp * 1000);
const dateFromTimestamp = (timestamp?: number) =>
timestamp !== undefined ? new Date(timestamp * 1000) : new Date();
const createNaiveDateWithOffset = (timestamp?: number, offset = '+00:00') => {
const date = dateFromTimestamp(timestamp);
const dateString = `${date.toISOString()}`.replace(/\.[\d]+Z/, offset);
return parseISO(dateString);
};
export const convertTimestampWithOffsetToPostgresString = (
dateTimestamp?: number,
offset?: string,
) => formatDateForPostgres(
createNaiveDateWithOffset(dateTimestamp, offset)
);
// Run on the server, when there are date/timestamp/offset inputs
export const convertTimestampToNaivePostgresString = (timestamp = 0) =>
new Date(timestamp * 1000).toISOString().replace(
/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(.[\d]+Z)*/,
'$1 $2',
);
export const convertTimestampWithOffsetToPostgresString = (
timestamp?: number,
offset?: string,
) =>
formatDateForPostgres(createNaiveDateWithOffset(timestamp, offset));
export const convertTimestampToNaivePostgresString = (timestamp?: number) =>
dateFromTimestamp(timestamp)
.toISOString().replace(
/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(.[\d]+Z)*/,
'$1 $2',
);
// Run in the browser, to get generate local date time strings
export const generateLocalPostgresString = () =>
formatDateForPostgres(new Date());
export const generateLocalNaivePostgresString = () =>
format(new Date(), DATE_STRING_FORMAT_POSTGRES);

View File

@ -10,16 +10,16 @@ export const getOffsetFromExif = (data: ExifData) =>
) as string | undefined;
export const formatFocalLength = (focalLength?: number) =>
focalLength !== undefined ? `${focalLength}mm` : undefined;
focalLength ? `${focalLength}mm` : undefined;
export const formatAperture = (aperture?: number) =>
aperture !== undefined ? `ƒ/${aperture}` : undefined;
aperture ? `ƒ/${aperture}` : undefined;
export const formatIso = (iso?: number) =>
iso !== undefined ? `ISO ${iso}` : undefined;
iso ? `ISO ${iso}` : undefined;
export const formatExposureTime = (exposureTime?: number) =>
exposureTime !== undefined
exposureTime
? `Shutter 1/${Math.floor(1 / (exposureTime ?? 1))}`
: undefined;
@ -37,7 +37,7 @@ const fractionForDecimal = (decimal: number, fractionCharacter?: boolean) => {
export const formatExposureCompensation = (exposureCompensation?: number) => {
if (
exposureCompensation !== undefined &&
exposureCompensation &&
Math.abs(exposureCompensation) >= 0.33
) {
const decimal = exposureCompensation % 1;