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 { updatedAt: Date createdAt: Date takenAt: Date tags: string[] | null } // Parsed db response export interface Photo extends Omit { 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, ) 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 = 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'));