Vercel/src/photo/index.ts
2026-02-01 19:05:58 -06:00

392 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { formatFocalLength } from '@/focal';
import { photoHasFilmData } from '@/film';
import {
SHOW_EXIF_DATA,
SHOW_FILMS,
SHOW_LENSES,
SHOW_RECIPES,
} from '@/app/config';
import { ABSOLUTE_PATH_HOME_IMAGE } from '@/app/path';
import { formatDate, formatDateFromPostgresString } from '@/utility/date';
import {
formatAperture,
formatIso,
formatExposureCompensation,
formatExposureTime,
} from '@/utility/exif-format';
import { capitalize, parameterize } from '@/utility/string';
import camelcaseKeys from 'camelcase-keys';
import { isBefore } from 'date-fns';
import type { Metadata } from 'next';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { PhotoUpdateStatus, generatePhotoUpdateStatus } from './update';
import { AppTextState } from '@/i18n/state';
import { PhotoColorData } from './color/client';
// INFINITE SCROLL: FULL
export const INFINITE_SCROLL_FULL_INITIAL =
process.env.NODE_ENV === 'development' ? 2 : 12;
export const INFINITE_SCROLL_FULL_MULTIPLE =
process.env.NODE_ENV === 'development' ? 2 : 24;
// INFINITE SCROLL: GRID
export const INFINITE_SCROLL_GRID_INITIAL =
process.env.NODE_ENV === 'development' ? 12 : 60;
export const INFINITE_SCROLL_GRID_MULTIPLE =
process.env.NODE_ENV === 'development' ? 12 : 60;
// Thumbnails below large photos on pages like /p/[photoId]
export const RELATED_GRID_PHOTOS_TO_SHOW = 12;
export const DEFAULT_ASPECT_RATIO = 1.5;
export const ACCEPTED_PHOTO_FILE_TYPES = [
'image/jpg',
'image/jpeg',
'image/png',
];
export const MAX_PHOTO_UPLOAD_SIZE_IN_BYTES = 50_000_000;
// Core EXIF data
export interface PhotoExif {
width?: number
height?: number
aspectRatio: number
make?: string
model?: string
focalLength?: number
focalLengthIn35MmFormat?: number
lensMake?: string
lensModel?: string
fNumber?: number
iso?: number
exposureTime?: number
exposureCompensation?: number
latitude?: number
longitude?: number
film?: string
recipeData?: string
takenAt?: string
takenAtNaive?: string
// Photo meta potentially located in EXIF/XMP data
title?: string
caption?: string
tags?: string[]
}
// Raw db insert
export interface PhotoDbInsert extends PhotoExif {
id: string
url: string
extension: string
blurData?: string
caption?: string
semanticDescription?: string
tags?: string[]
recipeTitle?: string
locationName?: string
colorData?: string
colorSort?: number
priorityOrder?: number
excludeFromFeeds?: boolean
hidden?: boolean
takenAt: string
takenAtNaive: string
}
// Raw db response
export interface PhotoDb extends
Omit<PhotoDbInsert, 'takenAt' | 'tags'> {
updatedAt: Date
createdAt: Date
takenAt: Date
tags: string[] | null
}
// Parsed db response
export interface Photo extends Omit<PhotoDb, 'recipeData' | 'colorData'> {
focalLengthFormatted?: string
focalLengthIn35MmFormatFormatted?: string
fNumberFormatted?: string
isoFormatted?: string
exposureTimeFormatted?: string
exposureCompensationFormatted?: string
takenAtNaiveFormatted: string
tags: string[]
recipeData?: FujifilmRecipe
colorData?: PhotoColorData
updateStatus?: PhotoUpdateStatus
}
export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
const photoDb = camelcaseKeys(
photoDbRaw as unknown as Record<string, unknown>,
) as unknown as PhotoDb;
return {
...photoDb,
tags: photoDb.tags ?? [],
focalLengthFormatted:
photoDb.focalLength
? formatFocalLength(photoDb.focalLength)
: undefined,
focalLengthIn35MmFormatFormatted:
photoDb.focalLengthIn35MmFormat
? formatFocalLength(photoDb.focalLengthIn35MmFormat)
: undefined,
fNumberFormatted:
formatAperture(photoDb.fNumber),
isoFormatted:
formatIso(photoDb.iso),
exposureTimeFormatted:
formatExposureTime(photoDb.exposureTime),
exposureCompensationFormatted:
formatExposureCompensation(photoDb.exposureCompensation),
takenAtNaiveFormatted:
formatDateFromPostgresString(photoDb.takenAtNaive),
recipeData: photoDb.recipeData
// Legacy check on escaped, string-based JSON
? typeof photoDb.recipeData === 'string'
? JSON.parse(photoDb.recipeData)
: photoDb.recipeData
: undefined,
colorData: photoDb.colorData
? photoDb.colorData
: undefined,
updateStatus: generatePhotoUpdateStatus(photoDb),
} as Photo;
};
export const parseCachedPhotoDates = (photo: Photo) => ({
...photo,
takenAt: new Date(photo.takenAt),
updatedAt: new Date(photo.updatedAt),
createdAt: new Date(photo.createdAt),
} as Photo);
export const parseCachedPhotosDates = (photos: Photo[]) =>
photos.map(parseCachedPhotoDates);
export const convertPhotoToPhotoDbInsert = (
photo: Photo,
): PhotoDbInsert => ({
...photo,
takenAt: photo.takenAt.toISOString(),
recipeData: JSON.stringify(photo.recipeData),
colorData: JSON.stringify(photo.colorData),
});
export const descriptionForPhoto = (
photo: Photo,
includeSemanticDescription?: boolean,
) =>
photo.caption ||
(includeSemanticDescription && photo.semanticDescription) ||
formatDate({ date: photo.takenAt }).toLocaleUpperCase();
export const getPreviousPhoto = (photo: Photo, photos: Photo[]) => {
const index = photos.findIndex(p => p.id === photo.id);
return index > 0
? photos[index - 1]
: undefined;
};
export const getNextPhoto = (photo: Photo, photos: Photo[]) => {
const index = photos.findIndex(p => p.id === photo.id);
return index < photos.length - 1
? photos[index + 1]
: undefined;
};
export const generateOgImageMetaForPhotos = (photos: Photo[]): Metadata => {
if (photos.length > 0) {
return {
openGraph: {
images: ABSOLUTE_PATH_HOME_IMAGE,
},
twitter: {
card: 'summary_large_image',
images: ABSOLUTE_PATH_HOME_IMAGE,
},
};
} else {
// If there are no photos, refrain from showing an OG image
return {};
}
};
const PHOTO_ID_FORWARDING_TABLE: Record<string, string> = JSON.parse(
process.env.PHOTO_ID_FORWARDING_TABLE || '{}',
);
export const translatePhotoId = (id: string) =>
PHOTO_ID_FORWARDING_TABLE[id] || id;
export const titleForPhoto = (
photo: Photo,
useDateAsTitle = true,
fallback = 'Untitled',
) => {
if (photo.title) {
return photo.title;
} else if (useDateAsTitle && (photo.takenAt || photo.createdAt)) {
return formatDate({
date: photo.takenAt || photo.createdAt,
length: 'tiny',
}).toLocaleUpperCase();
} else {
return fallback;
}
};
export const altTextForPhoto = (photo: Photo) =>
photo.semanticDescription || titleForPhoto(photo);
export const photoLabelForCount = (
count: number,
appText: AppTextState,
_capitalize = true,
) => {
const label = count === 1
? appText.photo.photo
: appText.photo.photoPlural;
return _capitalize
? capitalize(label)
: label.toLocaleLowerCase();
};
export const photoQuantityText = (
count: number,
appText: AppTextState,
includeParentheses = true,
capitalize?: boolean,
) =>
includeParentheses
? `(${count} ${photoLabelForCount(count, appText, capitalize)})`
: `${count} ${photoLabelForCount(count, appText, capitalize)}`;
export const deleteConfirmationTextForPhoto = (
photo: Photo,
appText: AppTextState,
) =>
appText.admin.deleteConfirm(titleForPhoto(photo));
export type PhotoDateRangePostgres = { start: string, end: string };
export type PhotoDateRangeFormatted = {
start: string,
end: string,
description: string,
descriptionWithSpaces: string,
};
export const descriptionForPhotoSet = (
photos:Photo[] = [],
appText: AppTextState,
descriptor?: string,
dateBased?: boolean,
explicitCount?: number,
explicitDateRange?: PhotoDateRangePostgres,
) =>
dateBased
? formattedDateRangeForPhotos(photos, explicitDateRange)
.description
.toLocaleUpperCase()
: [
explicitCount ?? photos.length, (
descriptor ||
photoLabelForCount(explicitCount ?? photos.length, appText, false)
),
].join(' ');
const sortPhotosByDateNonDestructively = (
photos: Photo[],
order: 'ASC' | 'DESC' = 'DESC',
) =>
[...photos].sort((a, b) => order === 'DESC'
? b.takenAt.getTime() - a.takenAt.getTime()
: a.takenAt.getTime() - b.takenAt.getTime());
export const formattedDateRangeForPhotos = (
photos: Photo[] = [],
explicitDateRange?: PhotoDateRangePostgres,
): PhotoDateRangeFormatted => {
let start = '';
let end = '';
let description = '';
let descriptionWithSpaces = '';
if (explicitDateRange || photos.length > 0) {
const photosSorted = sortPhotosByDateNonDestructively(photos);
start = formatDateFromPostgresString(
explicitDateRange?.start ?? photosSorted[photos.length - 1].takenAtNaive,
'short',
);
end = formatDateFromPostgresString(
explicitDateRange?.end ?? photosSorted[0].takenAtNaive,
'short',
);
description = start === end
? start
: `${start}${end}`;
descriptionWithSpaces = start === end
? start
: `${start} ${end}`;
}
return { start, end, description, descriptionWithSpaces };
};
const photoHasCameraData = (photo: Photo) =>
Boolean(photo.make) &&
Boolean(photo.model);
const photoHasLensData = (photo: Photo) =>
Boolean(photo.lensModel);
const photoHasRecipeData = (photo: Photo) =>
Boolean(photo.recipeData);
const photoHasExifData = (photo: Photo) =>
Boolean(photo.focalLength) ||
Boolean(photo.focalLengthIn35MmFormat) ||
Boolean(photo.fNumberFormatted) ||
Boolean(photo.isoFormatted) ||
Boolean(photo.exposureTimeFormatted) ||
Boolean(photo.exposureCompensationFormatted);
export const shouldShowCameraDataForPhoto = (photo: Photo) =>
SHOW_EXIF_DATA &&
photoHasCameraData(photo);
export const shouldShowLensDataForPhoto = (photo: Photo) =>
SHOW_EXIF_DATA &&
SHOW_LENSES &&
photoHasLensData(photo);
export const shouldShowRecipeDataForPhoto = (photo: Photo) =>
SHOW_EXIF_DATA &&
SHOW_RECIPES &&
photoHasRecipeData(photo);
export const shouldShowFilmDataForPhoto = (photo: Photo) =>
SHOW_EXIF_DATA &&
SHOW_FILMS &&
photoHasFilmData(photo);
export const shouldShowExifDataForPhoto = (photo: Photo) =>
SHOW_EXIF_DATA && photoHasExifData(photo);
export const getKeywordsForPhoto = (photo: Photo) =>
(photo.caption ?? '').split(' ')
.concat((photo.semanticDescription ?? '').split(' '))
.filter(Boolean)
.map(keyword => keyword.toLocaleLowerCase());
export const downloadFileNameForPhoto = (photo: Photo) =>
photo.title
? `${parameterize(photo.title)}.${photo.extension}`
: photo.url.split('/').pop() || 'download';
export const doesPhotoNeedBlurCompatibility = (photo: Photo) =>
isBefore(photo.updatedAt, new Date('2024-05-07'));