Merge pull request #197 from sambecker/recipes

Fujifilm Recipes
This commit is contained in:
Sam Becker 2025-02-24 09:01:29 -06:00 committed by GitHub
commit ea4a239304
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1121 additions and 199 deletions

View File

@ -130,6 +130,7 @@ Application behavior can be changed by configuring the following environment var
- `NEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1` hides taken at time from photo meta
- `NEXT_PUBLIC_HIDE_SOCIAL = 1` removes X (formerly Twitter) button from share modal
- `NEXT_PUBLIC_HIDE_FILM_SIMULATIONS = 1` prevents Fujifilm simulations showing up in `/grid` sidebar and CMD-K search results
- `NEXT_PUBLIC_HIDE_RECIPES = 1` prevents Fujifilm recipe button showing up in photo meta
- `NEXT_PUBLIC_HIDE_REPO_LINK = 1` removes footer link to repo
#### Grid
@ -282,6 +283,9 @@ Vercel Postgres can be switched to another Postgres-compatible, pooling provider
#### Why aren't Fujifilm simulations importing alongside EXIF data?
> Fujifilm simulation data is stored in vendor-specific Makernote binaries embedded in EXIF data. Under certain circumstances an intermediary may strip out this data. For instance, there is a known issue on iOS where editing an image, e.g., cropping it, causes Makernote data loss. If simulation data appears to be missing, try importing the original file as it was stored by the camera. Additionally, if you can confirm the simulation mode, you can edit the photo and manually select it.
#### My Fujifilm recipes are missing/displaying incorrect data. What should I do?
> Fujifilm file specifications have evolved over time. Open an issue with the file in question attached in order for it to be investigated.
#### Why do my images appear flipped/rotated incorrectly?
> For a number of reasons, only EXIF orientations: 1, 3, 6, and 8 are supported. Orientations 2, 4, 5, and 7—which make use of mirroring—are not supported.

View File

@ -0,0 +1,16 @@
import { processTone } from '@/platforms/fujifilm/recipe';
describe('Fujifilm', () => {
describe('recipes', () => {
it('process tone', () => {
expect(processTone(0)).toBe(0);
expect(processTone(8)).toBe(-0.5);
expect(processTone(16)).toBe(-1);
expect(processTone(32)).toBe(-2);
expect(processTone(-16)).toBe(1);
expect(processTone(-32)).toBe(2);
expect(processTone(-48)).toBe(3);
expect(processTone(-64)).toBe(4);
});
});
});

View File

@ -1,17 +1,12 @@
import { getPhotos } from '@/photo/db/query';
import { OUTDATED_THRESHOLD } from '@/photo';
import AdminOutdatedClient from '@/admin/AdminOutdatedClient';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { getOutdatedPhotos } from '@/photo/db/query';
export const maxDuration = 60;
export default async function AdminOutdatedPage() {
const photos = await getPhotos({
hidden: 'include',
sortBy: 'createdAtAsc',
updatedBefore: OUTDATED_THRESHOLD,
limit: 1_000,
}).catch(() => []);
const photos = await getOutdatedPhotos()
.catch(() => []);
return (
<AdminOutdatedClient {...{

View File

@ -1,11 +1,11 @@
import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache';
import { getPhotos } from '@/photo/db/query';
import { getPhotosMetaCached } from '@/photo/cache';
import { OUTDATED_THRESHOLD } from '@/photo';
import AdminPhotosClient from '@/admin/AdminPhotosClient';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone';
import { getOutdatedPhotosCount } from '@/photo/db/query';
export const maxDuration = 60;
@ -31,11 +31,7 @@ export default async function AdminPhotosPage() {
getPhotosMetaCached({ hidden: 'include'})
.then(({ count }) => count)
.catch(() => 0),
getPhotosMetaCached({
hidden: 'include',
updatedBefore: OUTDATED_THRESHOLD,
})
.then(({ count }) => count)
getOutdatedPhotosCount()
.catch(() => 0),
DEBUG_PHOTO_BLOBS
? getStoragePhotoUrlsNoStore()

View File

@ -0,0 +1,31 @@
import SiteGrid from '@/components/SiteGrid';
import { getPhoto, getPhotos } from '@/photo/db/query';
import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay';
export default async function AdminRecipePage({
params,
}: {
params: Promise<{ photoId: string }>
}) {
const { photoId } = await params;
const photo = await getPhoto(photoId);
const photosHidden = await getPhotos({ hidden: 'only' });
const { filmSimulation } = photo!;
const { fujifilmRecipe } = photosHidden[0];
return (
<SiteGrid
contentMain={photo && fujifilmRecipe && filmSimulation
? <PhotoRecipeOverlay
backgroundImageUrl={photo.url}
recipe={fujifilmRecipe}
simulation={filmSimulation}
exposure={photo.exposureCompensationFormatted ?? '+0ev'}
iso={photo.isoFormatted ?? 'ISO 0'}
/>
: <div>
Can&apos;t find photo/recipe
</div>}
/>
);
}

38
app/admin/recipe/page.tsx Normal file
View File

@ -0,0 +1,38 @@
import { getPhotos } from '@/photo/db/query';
import PhotoRecipeOverlay from '@/photo/PhotoRecipeOverlay';
export default async function AdminRecipePage() {
const photos = await getPhotos({ tag: 'favs', limit: 4});
const photosHidden = await getPhotos({ hidden: 'only', limit: 1 });
const { filmSimulation } = photosHidden[0];
const { fujifilmRecipe } = photosHidden[0];
return (
<div className="grid grid-cols-2 xl:grid-cols-3 w-full">
{photos.map(photo =>
<PhotoRecipeOverlay
key={photo.id}
backgroundImageUrl={photo.url}
recipe={fujifilmRecipe!}
simulation={filmSimulation!}
exposure={photo.exposureCompensationFormatted ?? '+0ev'}
iso={photo.isoFormatted ?? 'ISO 0'}
/>,
)}
<PhotoRecipeOverlay
key="black"
className="bg-black"
recipe={fujifilmRecipe!}
simulation={filmSimulation!}
exposure="+0ev"
iso="ISO 0"
/>
<PhotoRecipeOverlay
key="white"
className="bg-white"
recipe={fujifilmRecipe!}
simulation={filmSimulation!}
exposure="+0ev"
iso="ISO 0"
/>
</div>);
}

View File

@ -2,9 +2,10 @@
import SiteGrid from '@/components/SiteGrid';
import { clsx } from 'clsx/lite';
import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/platforms/fujifilm';
import PhotoFilmSimulation from
'@/simulation/PhotoFilmSimulation';
import {
FILM_SIMULATION_FORM_INPUT_OPTIONS,
} from '@/platforms/fujifilm/simulation';
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
import { useEffect, useState } from 'react';
export default function FilmPage() {

View File

@ -1,6 +1,7 @@
import { FILM_SIMULATION_FORM_INPUT_OPTIONS } from '@/platforms/fujifilm';
import PhotoFilmSimulation from
'@/simulation/PhotoFilmSimulation';
import {
FILM_SIMULATION_FORM_INPUT_OPTIONS,
} from '@/platforms/fujifilm/simulation';
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
export default function FilmPage() {
return (

View File

@ -38,7 +38,7 @@
"react-icons": "^5.5.0",
"sanitize-html": "^2.14.0",
"sharp": "^0.33.5",
"sonner": "^2.0.0",
"sonner": "^2.0.1",
"swr": "^2.3.2",
"ts-exif-parser": "^0.2.2",
"use-debounce": "^10.0.4",

10
pnpm-lock.yaml generated
View File

@ -96,8 +96,8 @@ importers:
specifier: ^0.33.5
version: 0.33.5
sonner:
specifier: ^2.0.0
version: 2.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: ^2.0.1
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
swr:
specifier: ^2.3.2
version: 2.3.2(react@19.0.0)
@ -3859,8 +3859,8 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
sonner@2.0.0:
resolution: {integrity: sha512-3WeSl3WrEdhmdiTniT8JsCiVRVDOdn7k+4MG2drqzSMOeqrExm03HIwDBPKuq52JBqL7wRLG17QBIiSleUbljw==}
sonner@2.0.1:
resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
@ -8805,7 +8805,7 @@ snapshots:
slash@3.0.0: {}
sonner@2.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
sonner@2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)

View File

@ -73,6 +73,7 @@ export default function AdminAppConfigurationClient({
showTakenAtTimeHidden,
showSocial,
showFilmSimulations,
showRecipes,
showRepoLink,
// Grid
isGridHomepageEnabled,
@ -525,6 +526,15 @@ export default function AdminAppConfigurationClient({
CMD-K results:
{renderEnvVars(['NEXT_PUBLIC_HIDE_FILM_SIMULATIONS'])}
</ChecklistRow>
<ChecklistRow
title="Show Fujifilm recipes"
status={showRecipes}
optional
>
Set environment variable to {'"1"'} to prevent
Fujifilm recipe button showing up in photo meta:
{renderEnvVars(['NEXT_PUBLIC_HIDE_RECIPES'])}
</ChecklistRow>
<ChecklistRow
title="Show repo link"
status={showRepoLink}

View File

@ -17,22 +17,22 @@ export default function AdminLink({
{...props}
href={href}
target="blank"
className={clsx(
className={className}
>
<span className={clsx(
'underline underline-offset-4',
'decoration-gray-300 dark:decoration-gray-700',
className,
)}
>
{children}
)}>
{children}
</span>
{externalIcon && <span className="whitespace-nowrap">
&nbsp;
<FiExternalLink
size={14}
className="inline translate-y-[-1.5px]"
/>
</span>}
</Link>
{externalIcon &&
<>
&nbsp;
<FiExternalLink
size={14}
className="inline translate-y-[-1.5px]"
/>
</>}
</>
);
}

View File

@ -1,6 +1,6 @@
'use client';
import { OUTDATED_THRESHOLD, Photo } from '@/photo';
import { Photo } from '@/photo';
import AdminPhotosTable from '@/admin/AdminPhotosTable';
import LoaderButton from '@/components/primitives/LoaderButton';
import IconGrSync from '@/app/IconGrSync';
@ -80,18 +80,13 @@ export default function AdminOutdatedClient({
<Note>
<div className="space-y-1.5">
<div className="font-bold">
Outdated photos found
{photos.length} outdated
{' '}
{photos.length === 1 ? 'photo' : 'photos'} found
</div>
{photos.length}
They may have missing EXIF fields, inaccurate blur data,
{' '}
{photos.length === 1 ? 'photo' : 'photos'}
{' ('}last updated before
{' '}
{new Date(OUTDATED_THRESHOLD).toLocaleDateString()}{')'}
{' '}
may have: missing EXIF fields, inaccurate blur data,
{' '}
undesired privacy settings, or missing AI-generated text
undesired privacy settings, or text that can be AI-generated
</div>
</Note>
<div className="space-y-4">

View File

@ -2,7 +2,11 @@
import { ComponentProps, useMemo } from 'react';
import { pathForAdminPhotoEdit, pathForPhoto } from '@/app/paths';
import { deletePhotoAction, toggleFavoritePhotoAction } from '@/photo/actions';
import {
deletePhotoAction,
syncPhotoAction,
toggleFavoritePhotoAction,
} from '@/photo/actions';
import { FaRegEdit, FaRegStar, FaStar } from 'react-icons/fa';
import {
Photo,
@ -17,6 +21,9 @@ import { useAppState } from '@/state/AppState';
import { RevalidatePhoto } from '@/photo/InfinitePhotoScroll';
import { MdOutlineFileDownload } from 'react-icons/md';
import MoreMenuItem from '@/components/more/MoreMenuItem';
import IconGrSync from '@/app/IconGrSync';
import { isPhotoOutdated } from '@/photo/outdated';
import { FaCircle } from 'react-icons/fa6';
export default function AdminPhotoMenuClient({
photo,
@ -65,17 +72,31 @@ export default function AdminPhotoMenuClient({
label: 'Download',
icon: <MdOutlineFileDownload
size={17}
className="translate-x-[-1.5px] translate-y-[-0.5px]"
className="translate-x-[-1px] translate-y-[-0.5px]"
/>,
href: photo.url,
hrefDownloadName: downloadFileNameForPhoto(photo),
});
items.push({
label: <span className="inline-flex items-center gap-2">
<span>Sync</span>
{isPhotoOutdated(photo) &&
<FaCircle
size={8}
className="text-amber-500 translate-y-[1.5px]"
/>}
</span>,
icon: <IconGrSync className="translate-x-[-1px]" />,
action: () => syncPhotoAction(photo.id)
.then(() => revalidatePhoto?.(photo.id)),
});
items.push({
label: 'Delete',
icon: <BiTrash
size={15}
className="translate-x-[-1.5px]"
className="translate-x-[-1px]"
/>,
className: 'text-error',
action: () => {
if (confirm(deleteConfirmationTextForPhoto(photo))) {
return deletePhotoAction(

View File

@ -12,8 +12,8 @@ import {
HAS_STATIC_OPTIMIZATION,
MATTE_PHOTOS,
} from '@/app/config';
import { OUTDATED_THRESHOLD } from '@/photo';
import { getGitHubMetaForCurrentApp, getSignificantInsights } from '.';
import { getOutdatedPhotosCount } from '@/photo/db/query';
const BASIC_PHOTO_INSTALLATION_COUNT = 32;
@ -21,7 +21,7 @@ export default async function AdminAppInsights() {
const [
{ count: photosCount, dateRange },
{ count: photosCountHidden },
{ count: photosCountOutdated },
photosCountOutdated,
{ count: photosCountPortrait },
tags,
cameras,
@ -31,7 +31,7 @@ export default async function AdminAppInsights() {
] = await Promise.all([
getPhotosMeta({ hidden: 'include' }),
getPhotosMeta({ hidden: 'only' }),
getPhotosMeta({ hidden: 'include', updatedBefore: OUTDATED_THRESHOLD }),
getOutdatedPhotosCount(),
getPhotosMeta({ maximumAspectRatio: 0.9 }),
getUniqueTags(),
getUniqueCameras(),

View File

@ -342,8 +342,10 @@ export default function AdminAppInsightsClient({
)}
/>}
content={renderHighlightText(
// eslint-disable-next-line max-len
pluralize(photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED, 'outdated photo'),
pluralize(
photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED,
'outdated photo',
),
'yellow',
)}
expandPath={PATH_ADMIN_OUTDATED}

View File

@ -6,18 +6,17 @@ import {
getSignificantInsights,
InsightIndicatorStatus,
} from '.';
import { getPhotosMeta } from '@/photo/db/query';
import { OUTDATED_THRESHOLD } from '@/photo';
import { getOutdatedPhotosCount } from '@/photo/db/query';
export const getShouldShowInsightsIndicatorAction =
async (): Promise<InsightIndicatorStatus> =>
runAuthenticatedAdminServerAction(async () => {
const [
codeMeta,
{ count: photosCountOutdated },
photosCountOutdated,
] = await Promise.all([
getGitHubMetaForCurrentApp(),
getPhotosMeta({ hidden: 'include', updatedBefore: OUTDATED_THRESHOLD }),
getOutdatedPhotosCount(),
]);
const {

View File

@ -18,7 +18,7 @@ import { formatCount, formatCountDescriptive } from '@/utility/string';
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
import { IoMdCamera } from 'react-icons/io';
import { ADMIN_DEBUG_TOOLS_ENABLED, SHOW_FILM_SIMULATIONS } from './config';
import { labelForFilmSimulation } from '@/platforms/fujifilm';
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
import { getUniqueFocalLengths } from '@/photo/db/query';
import { formatFocalLength } from '@/focal';
import { TbCone } from 'react-icons/tb';

View File

@ -217,6 +217,8 @@ export const SHOW_SOCIAL =
process.env.NEXT_PUBLIC_HIDE_SOCIAL !== '1';
export const SHOW_FILM_SIMULATIONS =
process.env.NEXT_PUBLIC_HIDE_FILM_SIMULATIONS !== '1';
export const SHOW_RECIPES =
process.env.NEXT_PUBLIC_HIDE_RECIPES !== '1';
export const SHOW_REPO_LINK =
process.env.NEXT_PUBLIC_HIDE_REPO_LINK !== '1';
@ -313,6 +315,7 @@ export const APP_CONFIGURATION = {
showTakenAtTimeHidden: SHOW_TAKEN_AT_TIME,
showSocial: SHOW_SOCIAL,
showFilmSimulations: SHOW_FILM_SIMULATIONS,
showRecipes: SHOW_RECIPES,
showRepoLink: SHOW_REPO_LINK,
// Grid
isGridHomepageEnabled: GRID_HOMEPAGE_ENABLED,

View File

@ -33,6 +33,10 @@ const PATH_CAMERA_DYNAMIC = `${PREFIX_CAMERA}/[make]/[model]`;
const PATH_FILM_SIMULATION_DYNAMIC = `${PREFIX_FILM_SIMULATION}/[simulation]`;
const PATH_FOCAL_LENGTH_DYNAMIC = `${PREFIX_FOCAL_LENGTH}/[focal]`;
// Search params
export const SEARCH_PARAM_SHOW = 'show';
export const SEARCH_PARAM_SHOW_RECIPE = 'recipe';
// Admin paths
export const PATH_ADMIN_PHOTOS = `${PATH_ADMIN}/photos`;
export const PATH_ADMIN_OUTDATED = `${PATH_ADMIN}/outdated`;
@ -78,7 +82,9 @@ export const PATHS_TO_CACHE = [
...PATHS_ADMIN,
];
type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetCategory;
type PhotoPathParams = { photo: PhotoOrPhotoId } & PhotoSetCategory & {
showRecipe?: boolean
};
// Absolute paths
export const ABSOLUTE_PATH_FOR_HOME_IMAGE = `${BASE_URL}/home-image`;
@ -103,8 +109,9 @@ export const pathForPhoto = ({
camera,
simulation,
focal,
}: PhotoPathParams) =>
typeof photo !== 'string' && photo.hidden
showRecipe,
}: PhotoPathParams) => {
const path = typeof photo !== 'string' && photo.hidden
? `${pathForTag(TAG_HIDDEN)}/${getPhotoId(photo)}`
: tag
? `${pathForTag(tag)}/${getPhotoId(photo)}`
@ -115,6 +122,10 @@ export const pathForPhoto = ({
: focal
? `${pathForFocalLength(focal)}/${getPhotoId(photo)}`
: `${PREFIX_PHOTO}/${getPhotoId(photo)}`;
return showRecipe
? `${path}?${SEARCH_PARAM_SHOW}=${SEARCH_PARAM_SHOW_RECIPE}`
: path;
};
export const pathForTag = (tag: string) =>
`${PREFIX_TAG}/${tag}`;

View File

@ -24,8 +24,9 @@ export type CameraWithCount = {
export type Cameras = CameraWithCount[];
export const createCameraKey = ({ make, model }: Camera) =>
parameterize(`${make}-${model}`, true);
// Support keys for make-only and model-only camera queries
export const createCameraKey = ({ make, model }: Partial<Camera>) =>
parameterize(`${make ?? 'ANY'}-${model ?? 'ANY'}`, true);
export const getCameraFromParams = ({
make,

View File

@ -5,7 +5,7 @@ export default function Badge({
className,
type = 'large',
dimContent,
highContrast,
contrast = 'low',
uppercase,
interactive,
}: {
@ -13,7 +13,7 @@ export default function Badge({
className?: string
type?: 'large' | 'small' | 'text-only'
dimContent?: boolean
highContrast?: boolean
contrast?: 'low' | 'medium' | 'high' | 'frosted'
uppercase?: boolean
interactive?: boolean
}) {
@ -30,13 +30,15 @@ export default function Badge({
return clsx(
'px-[5px] h-[17px] md:h-[18px]',
'text-[0.7rem] font-medium rounded-[0.25rem]',
highContrast
contrast === 'high'
? 'text-invert bg-invert'
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
interactive && (highContrast
: contrast === 'frosted'
? 'text-black bg-neutral-100/30 border border-neutral-200/40'
: 'text-medium bg-gray-300/30 dark:bg-gray-700/50',
interactive && (contrast === 'high'
? 'hover:opacity-70'
: 'hover:text-gray-900 dark:hover:text-gray-100'),
interactive && (highContrast
interactive && (contrast === 'high'
? 'active:opacity-90'
: 'active:bg-gray-200 dark:active:bg-gray-700/60'),
);

View File

@ -31,7 +31,7 @@ export default function Container({
case 'blue': return [
'text-blue-900 dark:text-blue-300',
'bg-blue-50/50 dark:bg-blue-950/30',
'border-blue-200 dark:border-blue-600/20',
'border-blue-200 dark:border-blue-500/40',
];
case 'red': return [
'text-red-600 dark:text-red-500/90',

View File

@ -1,5 +1,5 @@
import { clsx } from 'clsx/lite';
import { JSX, RefObject } from 'react';
import { HTMLAttributes, JSX, RefObject } from 'react';
/*
MAX WIDTHS
@ -18,6 +18,7 @@ export default function SiteGrid({
contentSide,
sideFirstOnMobile,
sideHiddenOnMobile,
...props
}: {
containerRef?: RefObject<HTMLDivElement | null>
className?: string
@ -25,9 +26,10 @@ export default function SiteGrid({
contentSide?: JSX.Element
sideFirstOnMobile?: boolean
sideHiddenOnMobile?: boolean
}) {
} & HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
ref={containerRef}
className={clsx(
'grid',

View File

@ -112,6 +112,7 @@ export default function CommandKClient({
shouldShowBaselineGrid,
shouldDebugImageFallbacks,
shouldDebugInsights,
shouldDebugRecipeOverlays,
setIsCommandKOpen: setIsOpen,
setShouldRespondToKeyboardCommands,
setShouldShowBaselineGrid,
@ -120,6 +121,7 @@ export default function CommandKClient({
setArePhotosMatted,
setShouldDebugImageFallbacks,
setShouldDebugInsights,
setShouldDebugRecipeOverlays,
} = useAppState();
const isOpenRef = useRef(isOpen);
@ -299,6 +301,11 @@ export default function CommandKClient({
setShouldDebugInsights,
shouldDebugInsights,
),
renderToggle(
'Recipe Overlays',
setShouldDebugRecipeOverlays,
shouldDebugRecipeOverlays,
),
],
});
}

View File

@ -12,6 +12,7 @@ export default function MoreMenuItem({
icon,
href,
hrefDownloadName,
className,
action,
shouldPreventDefault = true,
}: {
@ -19,6 +20,7 @@ export default function MoreMenuItem({
icon?: ReactNode
href?: string
hrefDownloadName?: string
className?: string
action?: () => Promise<void> | void
shouldPreventDefault?: boolean
}) {
@ -43,6 +45,7 @@ export default function MoreMenuItem({
isLoading
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer',
className,
)}
onClick={async e => {
if (shouldPreventDefault) { e.preventDefault(); }

View File

@ -1,6 +1,6 @@
'use client';
import { ReactNode } from 'react';
import { ComponentProps, ReactNode } from 'react';
import LabeledIcon, { LabeledIconType } from './LabeledIcon';
import Badge from '../Badge';
import { clsx } from 'clsx/lite';
@ -10,7 +10,7 @@ import Spinner from '../Spinner';
export interface EntityLinkExternalProps {
type?: LabeledIconType
badged?: boolean
contrast?: 'low' | 'medium' | 'high'
contrast?: ComponentProps<typeof Badge>['contrast']
prefetch?: boolean
}
@ -48,6 +48,8 @@ export default function EntityLink({
return 'text-dim';
case 'high':
return 'text-main';
case 'frosted':
return 'text-black';
default:
return 'text-medium';
}
@ -88,7 +90,7 @@ export default function EntityLink({
{badged
? <Badge
type="small"
highContrast={contrast === 'high'}
contrast={contrast}
className='translate-y-[-0.5px]'
uppercase
interactive
@ -105,9 +107,14 @@ export default function EntityLink({
<span className="hidden group-hover:inline text-dim">
{hoverEntity}
</span>}
{isLoading && <Spinner className={clsx(
badged && 'translate-y-[0.5px]',
)} />}
{isLoading &&
<Spinner
className={clsx(
badged && 'translate-y-[0.5px]',
contrast === 'frosted' && 'text-neutral-500',
)}
color={contrast === 'frosted' ? 'text' : undefined}
/>}
</>}
</LinkWithStatus>
</span>

View File

@ -4,7 +4,7 @@ import ImagePhotoGrid from './components/ImagePhotoGrid';
import ImageContainer from './components/ImageContainer';
import {
labelForFilmSimulation,
} from '@/platforms/fujifilm';
} from '@/platforms/fujifilm/simulation';
import PhotoFilmSimulationIcon from
'@/simulation/PhotoFilmSimulationIcon';
import { FilmSimulation } from '@/simulation';

View File

@ -12,10 +12,7 @@ import SiteGrid from '@/components/SiteGrid';
import ImageLarge from '@/components/image/ImageLarge';
import { clsx } from 'clsx/lite';
import Link from 'next/link';
import {
pathForFocalLength,
pathForPhoto,
} from '@/app/paths';
import { pathForFocalLength, pathForPhoto } from '@/app/paths';
import PhotoTags from '@/tag/PhotoTags';
import ShareButton from '@/share/ShareButton';
import DownloadButton from '@/components/DownloadButton';
@ -40,6 +37,11 @@ import { LuExpand } from 'react-icons/lu';
import LoaderButton from '@/components/primitives/LoaderButton';
import Tooltip from '@/components/Tooltip';
import ZoomControls, { ZoomControlsRef } from '@/components/image/ZoomControls';
import PhotoRecipe from './PhotoRecipe';
import { TbChecklist } from 'react-icons/tb';
import { IoCloseSharp } from 'react-icons/io5';
import { AnimatePresence } from 'framer-motion';
import useRecipeState from './useRecipeState';
export default function PhotoLarge({
photo,
@ -91,11 +93,23 @@ export default function PhotoLarge({
const {
areZoomControlsShown,
arePhotosMatted,
shouldDebugRecipeOverlays,
isUserSignedIn,
} = useAppState();
const showZoomControls = showZoomControlsProp && areZoomControlsShown;
const refRecipe = useRef<HTMLDivElement>(null);
const refRecipeTrigger = useRef<HTMLButtonElement>(null);
const {
shouldShowRecipe,
toggleRecipe,
hideRecipe,
} = useRecipeState({
ref: refRecipe,
refTrigger: refRecipeTrigger,
});
const tags = sortTags(photo.tags, primaryTag);
const camera = cameraFromPhoto(photo);
@ -142,6 +156,7 @@ export default function PhotoLarge({
const largePhotoContent =
<div className={clsx(
'relative',
arePhotosMatted && 'flex items-center justify-center',
// Always specify height to ensure fallback doesn't collapse
arePhotosMatted && 'h-[90%]',
@ -163,6 +178,24 @@ export default function PhotoLarge({
priority={priority}
/>
</ZoomControls>
<div className={clsx(
'absolute inset-0',
'flex items-center justify-center',
)}>
<AnimatePresence>
{(shouldShowRecipe || shouldDebugRecipeOverlays) &&
photo.fujifilmRecipe &&
photo.filmSimulation &&
<PhotoRecipe
ref={refRecipe}
recipe={photo.fujifilmRecipe}
simulation={photo.filmSimulation}
iso={photo.isoFormatted}
exposure={photo.exposureCompensationFormatted}
onClose={hideRecipe}
/>}
</AnimatePresence>
</div>
</div>;
const largePhotoContainerClassName = clsx(arePhotosMatted &&
@ -275,10 +308,30 @@ export default function PhotoLarge({
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
</ul>
{showSimulation && photo.filmSimulation &&
<PhotoFilmSimulation
simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks}
/>}
<div className="flex items-center gap-2 *:w-auto">
<PhotoFilmSimulation
simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks}
/>
{photo.fujifilmRecipe &&
<button
ref={refRecipeTrigger}
title="Fujifilm Recipe"
onClick={toggleRecipe}
className={clsx(
'text-medium',
'border-medium rounded-md',
'px-[4px] py-[2.5px] my-[-2.5px]',
'hover:bg-dim active:bg-main',
)}>
{shouldShowRecipe
? <IoCloseSharp size={15} />
: <TbChecklist
className="translate-x-[0.5px]"
size={15}
/>}
</button>}
</div>}
</>}
<div className={clsx(
'flex gap-x-3 gap-y-baseline',
@ -292,9 +345,8 @@ export default function PhotoLarge({
// Prevent collision with admin button
!hasNonDateContent && isUserSignedIn && 'md:pr-7',
)}
// 'createdAt' is a naive datetime which
// does not require a timezone and will not
// cause server/client time mismatches
// 'createdAt' is a naive datetime which does not require
// a timezone and will not cause server/client mismatch
timezone={null}
hideTime={!SHOW_TAKEN_AT_TIME}
/>

158
src/photo/PhotoRecipe.tsx Normal file
View File

@ -0,0 +1,158 @@
'use client';
import LoaderButton from '@/components/primitives/LoaderButton';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
import PhotoFilmSimulation from '@/simulation/PhotoFilmSimulation';
import clsx from 'clsx/lite';
import { ReactNode, RefObject } from 'react';
import { IoCloseCircle } from 'react-icons/io5';
import { motion } from 'framer-motion';
const addSign = (value = 0) => value < 0 ? value : `+${value}`;
export default function PhotoRecipe({
ref,
recipe: {
dynamicRange,
whiteBalance,
highISONoiseReduction,
noiseReductionBasic,
highlight,
shadow,
color,
sharpness,
clarity,
colorChromeEffect,
colorChromeFXBlue,
grainEffect,
bwAdjustment,
bwMagentaGreen,
},
simulation,
iso,
exposure,
onClose,
}: {
ref?: RefObject<HTMLDivElement | null>
recipe: FujifilmRecipe
simulation: FilmSimulation
iso?: string
exposure?: string
onClose?: () => void
}) {
const whiteBalanceTypeFormatted = whiteBalance.type
.replace(/auto./i, '')
.replaceAll('-', ' ');
const renderRow = (children: ReactNode) =>
<div className="flex gap-2 *:w-full *:grow">{children}</div>;
const renderDataSquare = (
value: ReactNode,
label?: string,
className?: string,
) => (
<div className={clsx(
'flex flex-col items-center justify-center gap-0.5 rounded-md min-w-0',
'rounded-md border',
'border-neutral-200/40',
'bg-neutral-100/30 hover:bg-neutral-100/50',
label && 'p-1',
className,
)}>
<div className="truncate max-w-full tracking-wide">
{typeof value === 'number' ? addSign(value) : value}
</div>
{label && <div className={clsx(
'text-[10px] leading-none tracking-wide font-medium text-black/50',
'uppercase',
)}>
{label}
</div>}
</div>
);
return (
<motion.div
ref={ref}
initial={{ opacity: 0, translateY: -10 }}
animate={{ opacity: 1, translateY: 0 }}
exit={{ opacity: 0, translateY: -10 }}
className={clsx(
'z-10',
'w-[19rem] p-3 space-y-3',
'rounded-lg shadow-2xl',
'text-[13.5px] text-black',
'bg-white/70 border border-neutral-200/30',
'backdrop-blur-xl saturate-[300%]',
)}
>
<div className="flex items-center gap-2">
<PhotoFilmSimulation
contrast="frosted"
className="grow"
simulation={simulation}
/>
<LoaderButton
icon={<IoCloseCircle size={20} />}
onClick={onClose}
className={clsx(
'link p-0 m-0 h-4!',
'text-black/40 active:text-black/75',
)}
/>
</div>
<div className="space-y-2">
{renderRow(<>
{renderDataSquare(`DR${dynamicRange.development}`)}
{renderDataSquare(iso)}
{renderDataSquare(exposure ?? '0ev')}
</>)}
{renderRow(<>
{renderDataSquare(
whiteBalanceTypeFormatted.toUpperCase(),
`R${addSign(whiteBalance?.red)} / B${addSign(whiteBalance?.blue)}`,
'basis-2/3',
)}
{renderDataSquare(
highISONoiseReduction ?? noiseReductionBasic ?? 'OFF',
'ISO NR',
'basis-1/3',
)}
</>)}
{renderRow(<>
{renderDataSquare(highlight, 'Highlight')}
{renderDataSquare(shadow, 'Shadow')}
</>)}
{renderRow(<>
{renderDataSquare(color, 'Color')}
{renderDataSquare(sharpness, 'Sharpness')}
{renderDataSquare(clarity, 'Clarity')}
</>)}
{renderRow(<>
{renderDataSquare(
colorChromeEffect?.toLocaleUpperCase() ?? 'N/A',
'Color Chrome',
)}
{renderDataSquare(
colorChromeFXBlue?.toLocaleUpperCase() ?? 'N/A',
'FX Blue',
)}
</>)}
{renderRow(<>
{renderDataSquare(
grainEffect.roughness.toLocaleUpperCase(),
grainEffect.size === 'large'
? 'Large Grain'
: grainEffect.size === 'small'
? 'Small Grain'
: 'Grain',
)}
{renderDataSquare(bwAdjustment ?? 0, 'BW ADJ')}
{renderDataSquare(bwMagentaGreen ?? 0, 'BW M/G')}
</>)}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,45 @@
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FilmSimulation } from '@/simulation';
import clsx from 'clsx/lite';
import ImageLarge from '@/components/image/ImageLarge';
import PhotoRecipe from './PhotoRecipe';
export default function PhotoRecipeOverlay({
backgroundImageUrl,
recipe,
simulation,
exposure,
iso,
className,
}: {
backgroundImageUrl?: string
recipe: FujifilmRecipe
simulation: FilmSimulation
exposure: string
iso: string
className?: string
}) {
return (
<div className={clsx(
'relative w-full aspect-[3/2]',
className,
)}>
{backgroundImageUrl &&<ImageLarge
src={backgroundImageUrl}
alt="Image Background"
aspectRatio={3 / 2}
/>}
<div className={clsx(
'absolute inset-0',
'flex items-center justify-center',
)}>
<PhotoRecipe {...{
recipe,
simulation,
exposure,
iso,
}} />
</div>
</div>
);
}

View File

@ -362,6 +362,8 @@ export const syncPhotoAction = async (photoId: string) =>
const photoFormDbInsert = convertFormDataToPhotoDbInsert({
...convertPhotoToFormData(photo),
...photoFormExif,
// Don't overwrite manually configured film simulations
...photo.filmSimulation && { filmSimulation: photo.filmSimulation },
...!BLUR_ENABLED && { blurData: undefined },
...!photo.title && { title: atTitle },
...!photo.caption && { caption: aiCaption },

View File

@ -1,6 +1,7 @@
import { PRIORITY_ORDER_ENABLED } from '@/app/config';
import { parameterize } from '@/utility/string';
import { PhotoSetCategory } from '..';
import { Camera } from '@/camera';
export const GENERATE_STATIC_PARAMS_LIMIT = 1000;
export const PHOTO_DEFAULT_LIMIT = 100;
@ -22,7 +23,9 @@ export type GetPhotosOptions = {
takenAfterInclusive?: Date
updatedBefore?: Date
hidden?: 'exclude' | 'include' | 'only'
} & PhotoSetCategory;
} & Omit<PhotoSetCategory, 'camera'> & {
camera?: Partial<Camera>
};
export const areOptionsSensitive = (options: GetPhotosOptions) =>
options.hidden === 'include' || options.hidden === 'only';
@ -83,9 +86,11 @@ export const getWheresFromOptions = (
wheres.push(`$${valuesIndex++}=ANY(tags)`);
wheresValues.push(tag);
}
if (camera) {
if (camera?.make) {
wheres.push(`${parameterizeForDb('make')}=$${valuesIndex++}`);
wheresValues.push(parameterize(camera.make, true));
}
if (camera?.model) {
wheres.push(`${parameterizeForDb('model')}=$${valuesIndex++}`);
wheresValues.push(parameterize(camera.model, true));
}

40
src/photo/db/migration.ts Normal file
View File

@ -0,0 +1,40 @@
import { sql } from '@/platforms/postgres';
interface Migration {
label: string
fields: string[]
run: () => ReturnType<typeof sql>
}
export const MIGRATIONS: Migration[] = [{
label: '01: AI Text Generation',
fields: ['caption', 'semantic_description'],
run: () => sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS caption TEXT,
ADD COLUMN IF NOT EXISTS semantic_description TEXT
`,
}, {
label: '02: Lens Metadata',
fields: ['lens_make', 'lens_model'],
run: () => sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS lens_make VARCHAR(255),
ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255)
`,
}, {
label: '03: Fujifilm Recipe',
fields: ['fujifilm_recipe'],
run: () => sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS fujifilm_recipe JSONB
`,
}];
export const migrationForError = (e: any) =>
MIGRATIONS.find(migration =>
migration.fields.some(field =>
new RegExp(`column "${field}" of relation "photos" does not exist`, 'i')
.test(e.message),
),
);

View File

@ -23,6 +23,9 @@ import {
import { getWheresFromOptions } from '.';
import { FocalLengths } from '@/focal';
import { Lenses, createLensKey } from '@/lens';
import { migrationForError } from './migration';
import { UPDATED_BEFORE_01, UPDATED_BEFORE_02 } from '../outdated';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
const createPhotosTable = () =>
sql`
@ -50,6 +53,7 @@ const createPhotosTable = () =>
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
film_simulation VARCHAR(255),
fujifilm_recipe JSONB,
priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL,
@ -59,24 +63,6 @@ const createPhotosTable = () =>
)
`;
// Migration 01
const MIGRATION_FIELDS_01 = ['caption', 'semantic_description'];
const runMigration01 = () =>
sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS caption TEXT,
ADD COLUMN IF NOT EXISTS semantic_description TEXT
`;
// Migration 02
const MIGRATION_FIELDS_02 = ['lens_make', 'lens_model'];
const runMigration02 = () =>
sql`
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS lens_make VARCHAR(255),
ADD COLUMN IF NOT EXISTS lens_model VARCHAR(255)
`;
// Wrapper for most queries for JIT table creation/migration running
const safelyQueryPhotos = async <T>(
callback: () => Promise<T>,
@ -89,19 +75,10 @@ const safelyQueryPhotos = async <T>(
try {
result = await callback();
} catch (e: any) {
if (MIGRATION_FIELDS_01.some(field => new RegExp(
`column "${field}" of relation "photos" does not exist`,
'i',
).test(e.message))) {
console.log('Running migration 01 ...');
await runMigration01();
result = await callback();
} else if (MIGRATION_FIELDS_02.some(field => new RegExp(
`column "${field}" of relation "photos" does not exist`,
'i',
).test(e.message))) {
console.log('Running migration 02 ...');
await runMigration02();
const migration = migrationForError(e);
if (migration) {
console.log(`Running Migration ${migration.label} ...`);
await migration.run();
result = await callback();
} else if (/relation "photos" does not exist/i.test(e.message)) {
// If the table does not exist, create it
@ -163,6 +140,7 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
latitude,
longitude,
film_simulation,
fujifilm_recipe,
priority_order,
hidden,
taken_at,
@ -192,6 +170,7 @@ export const insertPhoto = (photo: PhotoDbInsert) =>
${photo.latitude},
${photo.longitude},
${photo.filmSimulation},
${JSON.stringify(photo.fujifilmRecipe)},
${photo.priorityOrder},
${photo.hidden},
${photo.takenAt},
@ -224,6 +203,7 @@ export const updatePhoto = (photo: PhotoDbInsert) =>
latitude=${photo.latitude},
longitude=${photo.longitude},
film_simulation=${photo.filmSimulation},
fujifilm_recipe=${JSON.stringify(photo.fujifilmRecipe)},
priority_order=${photo.priorityOrder || null},
hidden=${photo.hidden},
taken_at=${photo.takenAt},
@ -467,3 +447,39 @@ export const getPhoto = async (
.then(({ rows }) => rows.map(parsePhotoFromDb))
.then(photos => photos.length > 0 ? photos[0] : undefined);
}, 'getPhoto');
// Outdated queries
const outdatedWhereClause =
// eslint-disable-next-line quotes
`WHERE updated_at < $1 OR (updated_at < $2 AND make = $3)`;
const outdatedValues = [
UPDATED_BEFORE_01.toISOString(),
UPDATED_BEFORE_02.toISOString(),
MAKE_FUJIFILM,
];
export const getOutdatedPhotos = () => safelyQueryPhotos(
() => query(`
SELECT * FROM photos
${outdatedWhereClause}
ORDER BY created_at ASC
LIMIT 1000
`,
outdatedValues,
)
.then(({ rows }) => rows.map(parsePhotoFromDb)),
'getOutdatedPhotos',
);
export const getOutdatedPhotosCount = () => safelyQueryPhotos(
() => query(`
SELECT COUNT(*) FROM photos
${outdatedWhereClause}
`,
outdatedValues,
)
.then(({ rows }) => parseInt(rows[0].count, 10)),
'getOutdatedPhotosCount',
);

View File

@ -101,10 +101,7 @@ export default function PhotoForm({
if (changedKeys.length > 0) {
const fields = convertFormKeysToLabels(changedKeys);
toastSuccess(
`Updated EXIF fields: ${fields.join(', ')}`,
8000,
);
toastSuccess(`Updated EXIF fields: ${fields.join(', ')}`, 8000);
} else {
toastWarning('No new EXIF data found');
}

View File

@ -18,11 +18,12 @@ import { convertStringToArray } from '@/utility/string';
import { generateNanoid } from '@/utility/nanoid';
import {
FILM_SIMULATION_FORM_INPUT_OPTIONS,
MAKE_FUJIFILM,
} from '@/platforms/fujifilm';
} from '@/platforms/fujifilm/simulation';
import { FilmSimulation } from '@/simulation';
import { GEO_PRIVACY_ENABLED } from '@/app/config';
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
type VirtualFields = 'favorite';
@ -107,6 +108,12 @@ const FORM_METADATA = (
selectOptionsDefaultLabel: 'Unknown',
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
},
fujifilmRecipe: {
type: 'textarea',
label: 'fujifilm recipe',
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
readOnly: true,
},
focalLength: { label: 'focal length' },
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
lensMake: { label: 'lens make' },
@ -181,6 +188,8 @@ export const convertPhotoToFormData = (
return value?.toISOString ? value.toISOString() : value;
case 'hidden':
return value ? 'true' : 'false';
case 'fujifilmRecipe':
return JSON.stringify(value);
default:
return value !== undefined && value !== null
? value.toString()
@ -200,6 +209,7 @@ export const convertPhotoToFormData = (
export const convertExifToFormData = (
data: ExifData,
filmSimulation?: FilmSimulation,
fujifilmRecipe?: FujifilmRecipe,
): Omit<
Record<keyof PhotoExif, string | undefined>,
'takenAt' | 'takenAtNaive'
@ -223,6 +233,7 @@ export const convertExifToFormData = (
longitude:
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
filmSimulation,
fujifilmRecipe: JSON.stringify(fujifilmRecipe),
...data.tags?.DateTimeOriginal && {
takenAt: convertTimestampWithOffsetToPostgresString(
data.tags.DateTimeOriginal,
@ -267,7 +278,10 @@ export const convertFormDataToPhotoDbInsert = (
});
return {
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
...(photoForm as PhotoFormData & {
filmSimulation?: FilmSimulation
fujifilmRecipe?: FujifilmRecipe
}),
...!photoForm.id && { id: generateNanoid() },
// Delete array field when empty
tags: tags.length > 0 ? tags : undefined,

View File

@ -20,8 +20,7 @@ import { parameterize } from '@/utility/string';
import camelcaseKeys from 'camelcase-keys';
import { isBefore } from 'date-fns';
import type { Metadata } from 'next';
export const OUTDATED_THRESHOLD = new Date('2024-06-16');
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
// INFINITE SCROLL: FEED
export const INFINITE_SCROLL_FEED_INITIAL =
@ -66,6 +65,7 @@ export interface PhotoExif {
latitude?: number
longitude?: number
filmSimulation?: FilmSimulation
fujifilmRecipe?: string
takenAt?: string
takenAtNaive?: string
}
@ -88,7 +88,8 @@ export interface PhotoDbInsert extends PhotoExif {
}
// Raw db response
export interface PhotoDb extends Omit<PhotoDbInsert, 'takenAt' | 'tags'> {
export interface PhotoDb extends
Omit<PhotoDbInsert, 'takenAt' | 'tags'> {
updatedAt: Date
createdAt: Date
takenAt: Date
@ -96,7 +97,7 @@ export interface PhotoDb extends Omit<PhotoDbInsert, 'takenAt' | 'tags'> {
}
// Parsed db response
export interface Photo extends PhotoDb {
export interface Photo extends Omit<PhotoDb, 'fujifilmRecipe'> {
focalLengthFormatted?: string
focalLengthIn35MmFormatFormatted?: string
fNumberFormatted?: string
@ -104,6 +105,7 @@ export interface Photo extends PhotoDb {
exposureTimeFormatted?: string
exposureCompensationFormatted?: string
takenAtNaiveFormatted: string
fujifilmRecipe?: FujifilmRecipe
}
export interface PhotoSetCategory {
@ -139,6 +141,9 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
formatExposureTime(photoDb.exposureTime),
exposureCompensationFormatted:
formatExposureCompensation(photoDb.exposureCompensation),
fujifilmRecipe: photoDb.fujifilmRecipe
? JSON.parse(photoDb.fujifilmRecipe)
: undefined,
takenAtNaiveFormatted:
formatDateFromPostgresString(photoDb.takenAtNaive),
};
@ -159,6 +164,7 @@ export const convertPhotoToPhotoDbInsert = (
): PhotoDbInsert => ({
...photo,
takenAt: photo.takenAt.toISOString(),
fujifilmRecipe: JSON.stringify(photo.fujifilmRecipe),
});
export const photoStatsAsString = (photo: Photo) => [

13
src/photo/outdated.ts Normal file
View File

@ -0,0 +1,13 @@
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Photo } from '.';
export const UPDATED_BEFORE_01 = new Date('2024-06-16');
// UTC 2025-02-24 05:30:00
export const UPDATED_BEFORE_02 = new Date(Date.UTC(2025, 1, 24, 5, 30, 0));
export const isPhotoOutdated = (photo: Photo) => {
return photo.updatedAt < UPDATED_BEFORE_01 || (
photo.updatedAt < UPDATED_BEFORE_02 &&
photo.make === MAKE_FUJIFILM
);
};

View File

@ -5,8 +5,7 @@ import {
import { convertExifToFormData } from '@/photo/form';
import {
getFujifilmSimulationFromMakerNote,
isExifForFujifilm,
} from '@/platforms/fujifilm';
} from '@/platforms/fujifilm/simulation';
import { ExifData, ExifParserFactory } from 'ts-exif-parser';
import { PhotoFormData } from './form';
import { FilmSimulation } from '@/simulation';
@ -15,7 +14,11 @@ import {
GEO_PRIVACY_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
} from '@/app/config';
import { isExifForFujifilm } from '@/platforms/fujifilm';
import {
FujifilmRecipe,
getFujifilmRecipeFromMakerNote,
} from '@/platforms/fujifilm/recipe';
const IMAGE_WIDTH_RESIZE = 200;
const IMAGE_WIDTH_BLUR = 200;
@ -48,6 +51,7 @@ export const extractImageDataFromBlobPath = async (
let exifData: ExifData | undefined;
let filmSimulation: FilmSimulation | undefined;
let recipe: FujifilmRecipe | undefined;
let blurData: string | undefined;
let imageResizedBase64: string | undefined;
let shouldStripGpsData = false;
@ -78,6 +82,7 @@ export const extractImageDataFromBlobPath = async (
const makerNote = exifDataBinary.tags?.MakerNote;
if (Buffer.isBuffer(makerNote)) {
filmSimulation = getFujifilmSimulationFromMakerNote(makerNote);
recipe = getFujifilmRecipeFromMakerNote(makerNote);
}
}
@ -111,7 +116,7 @@ export const extractImageDataFromBlobPath = async (
url,
},
...generateBlurData && { blurData },
...convertExifToFormData(exifData, filmSimulation),
...convertExifToFormData(exifData, filmSimulation, recipe),
},
},
imageResizedBase64,

View File

@ -0,0 +1,87 @@
import {
getPathComponents,
pathForPhoto,
SEARCH_PARAM_SHOW_RECIPE,
} from '@/app/paths';
import { usePathname } from 'next/navigation';
import { SEARCH_PARAM_SHOW } from '@/app/paths';
import { useSearchParams } from 'next/navigation';
import { RefObject, useCallback, useEffect, useState } from 'react';
import { isElementEntirelyInViewport } from '@/utility/dom';
import useClickInsideOutside from '@/utility/useClickInsideOutside';
export default function useRecipeState({
ref,
refTrigger,
}: {
ref?: RefObject<HTMLElement | null>,
refTrigger?: RefObject<HTMLElement | null>,
}) {
const pathname = usePathname();
const params = useSearchParams();
const {
photoId,
...pathComponents
} = getPathComponents(pathname);
const showRecipeInitially =
params.get(SEARCH_PARAM_SHOW) === SEARCH_PARAM_SHOW_RECIPE;
const [shouldShowRecipe, setShouldShowRecipe] = useState(showRecipeInitially);
const setVisibility = useCallback((shouldShow: boolean) => {
if (shouldShow) {
setShouldShowRecipe(true);
// Only add query param for photo details
if (photoId) {
window.history.pushState(
null,
'',
pathForPhoto({
photo: photoId,
...pathComponents,
showRecipe: true,
}),
);
}
} else {
setShouldShowRecipe(false);
// Only remove query param for photo details
if (photoId) {
window.history.pushState(
null,
'',
pathForPhoto({
photo: photoId,
...pathComponents,
}),
);
}
}
}, [pathComponents, photoId]);
const showRecipe = useCallback(() => setVisibility(true), [setVisibility]);
const hideRecipe = useCallback(() => setVisibility(false), [setVisibility]);
const toggleRecipe = useCallback(() =>
setVisibility(!shouldShowRecipe),
[setVisibility, shouldShowRecipe]);
useClickInsideOutside({
htmlElements: [ref, refTrigger],
onClickOutside: hideRecipe,
});
useEffect(() => {
if (shouldShowRecipe && !isElementEntirelyInViewport(ref?.current)) {
ref?.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [ref, shouldShowRecipe]);
return {
shouldShowRecipe,
showRecipe,
hideRecipe,
toggleRecipe,
};
}

View File

@ -0,0 +1,81 @@
// MakerNote tag IDs and values referenced from:
// - github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm
// - exiftool.org/TagNames/FujiFilm.html
import type { ExifData } from 'ts-exif-parser';
export const MAKE_FUJIFILM = 'FUJIFILM';
// Makernote Offsets
const BYTE_OFFSET_TAG_COUNT = 12;
const BYTE_OFFSET_FIRST_TAG = 14;
// Tag Offsets
const BYTE_OFFSET_TAG_TYPE = 2;
const BYTE_OFFSET_TAG_SIZE = 4;
const BYTE_OFFSET_TAG_VALUE = 8;
// Tag Sizes
const BYTES_PER_TAG = 12;
const BYTES_PER_TAG_VALUE = 4;
export const isExifForFujifilm = (data: ExifData) =>
data.tags?.Make === MAKE_FUJIFILM;
export const parseFujifilmMakerNote = (
bytes: Buffer,
sendTagNumbers: (tagId: number, numbers: number[]) => void,
) => {
const tagCount = bytes.readUint16LE(BYTE_OFFSET_TAG_COUNT);
for (let i = 0; i < tagCount; i++) {
const index = BYTE_OFFSET_FIRST_TAG + i * BYTES_PER_TAG;
if (index + BYTES_PER_TAG < bytes.length) {
const tagId = bytes.readUInt16LE(index);
const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE);
const tagValueSize = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_SIZE);
const sendNumbersForDataType = (
parseNumberAtOffset: (offset: number) => number,
sizeInBytes: number,
) => {
let values: number[] = [];
if (tagValueSize * sizeInBytes <= BYTES_PER_TAG_VALUE) {
// Retrieve values if they fit in tag block
values = Array.from({ length: tagValueSize }, (_, i) =>
parseNumberAtOffset(
index + BYTE_OFFSET_TAG_VALUE + i * sizeInBytes,
),
);
} else {
// Retrieve outside values if they don't fit in tag block
const offset = bytes.readUint16LE(index + BYTE_OFFSET_TAG_VALUE);
for (let i = 0; i < tagValueSize; i++) {
values.push(parseNumberAtOffset(offset + i * sizeInBytes));
}
}
sendTagNumbers(tagId, values);
};
switch (tagType) {
// Int8 (UInt8 read as Int8 according to spec)
case 1:
sendNumbersForDataType(offset => bytes.readInt8(offset), 1);
break;
// UInt16
case 3:
sendNumbersForDataType(offset => bytes.readUInt16LE(offset), 2);
break;
// UInt32
case 4:
sendNumbersForDataType(offset => bytes.readUInt32LE(offset), 4);
break;
// Int32
case 9:
sendNumbersForDataType(offset => bytes.readInt32LE(offset), 4);
break;
}
}
}
};

View File

@ -0,0 +1,257 @@
import { parseFujifilmMakerNote } from '.';
const TAG_ID_DYNAMIC_RANGE = 0x1400;
const TAG_ID_DYNAMIC_RANGE_SETTING = 0x1402;
const TAG_ID_DEVELOPMENT_DYNAMIC_RANGE = 0x1403;
const TAG_ID_WHITE_BALANCE = 0x1002;
const TAG_ID_WHITE_BALANCE_FINE_TUNE = 0x100a;
const TAG_ID_NOISE_REDUCTION = 0x100e;
const TAG_ID_NOISE_REDUCTION_BASIC = 0x100b;
const TAG_ID_HIGHLIGHT = 0x1041;
const TAG_ID_SHADOW = 0x1040;
const TAG_ID_SATURATION = 0x1003;
const TAG_ID_SHARPNESS = 0x1001;
const TAG_ID_CLARITY = 0x100f;
const TAG_ID_COLOR_CHROME_EFFECT = 0x1048;
const TAG_ID_COLOR_CHROME_FX_BLUE = 0x104e;
const TAG_ID_GRAIN_EFFECT_ROUGHNESS = 0x1047;
const TAG_ID_GRAIN_EFFECT_SIZE = 0x104c;
const TAG_ID_BW_ADJUSTMENT = 0x1049;
const TAG_ID_BW_MAGENTA_GREEN = 0x104b;
type WeakStrong = 'off' | 'weak' | 'strong';
export type FujifilmRecipe = {
dynamicRange: {
range: 'standard' | 'wide'
// eslint-disable-next-line max-len
setting: 'auto' | 'manual' | 'standard' | 'wide-1' | 'wide-2' | 'film-simulation'
development: number
}
whiteBalance: {
type: string
red: number
blue: number
}
highISONoiseReduction?: number
noiseReductionBasic?: string
highlight?: number
shadow?: number
color?: number
sharpness?: number
clarity?: number
colorChromeEffect?: WeakStrong
colorChromeFXBlue?: WeakStrong
grainEffect: {
roughness: WeakStrong
size: 'off' | 'small' | 'large'
}
bwAdjustment?: number
bwMagentaGreen?: number
};
const DEFAULT_DYNAMIC_RANGE = {
range: 'standard',
setting: 'auto',
development: 100,
} as const;
const DEFAULT_WHITE_BALANCE = {
type: 'auto',
red: 0,
blue: 0,
} as const;
const DEFAULT_GRAIN_EFFECT = {
roughness: 'off',
size: 'off',
} as const;
export const processDynamicRangeSettings = (
value: number,
): FujifilmRecipe['dynamicRange']['setting'] => {
switch (value) {
case 0x001: return 'manual';
case 0x100: return 'standard';
case 0x200: return 'wide-1';
case 0x201: return 'wide-1';
case 0x8000: return 'film-simulation';
default: return 'auto';
}
};
export const processTone = (value: number) =>
value === 0 ? 0 : -(value / 16);
export const processSaturation = (value: number) => {
switch (value) {
case 0x4e0: return -4;
case 0x4c0: return -3;
case 0x400: return -2;
case 0x180: return -1;
case 0x80: return 1;
case 0x100: return 2;
case 0xc0: return 3;
case 0xe0: return 4;
default: return 0;
}
};
export const processNoiseReductionLegacy = (value: number) => {
switch (value) {
case 0x40: return 'low';
case 0x80: return 'normal';
default: return 'n/a';
}
};
export const processNoiseReduction = (value: number) => {
switch (value) {
case 0x2e0: return -4;
case 0x2c0: return -3;
case 0x200: return -2;
case 0x280: return -1;
case 0x180: return 1;
case 0x100: return 2;
case 0x1c0: return 3;
case 0x1e0: return 4;
default: return 0;
}
};
export const processSharpness = (value: number) => {
switch (value) {
case 0x0: return -4;
case 0x1: return -3;
case 0x2: return -2;
case 0x82: return -1;
case 0x84: return 1;
case 0x4: return 2;
case 0x5: return 3;
case 0x6: return 4;
default: return 0;
}
};
export const processClarity = (value: number) => value / 1000;
export const processWeakStrong = (value: number): WeakStrong => {
switch (value) {
case 32: return 'weak';
case 64: return 'strong';
default: return 'off';
}
};
export const processGrainEffectSize = (
value: number,
): Required<FujifilmRecipe>['grainEffect']['size'] => {
switch (value) {
case 16: return 'small';
case 32: return 'large';
default: return 'off';
}
};
export const processWhiteBalanceType = (value: number) => {
switch (value) {
case 0x1: return 'auto-white-priority';
case 0x2: return 'auto-ambiance-priority';
case 0x100: return 'daylight';
case 0x200: return 'cloudy';
case 0x300: return 'daylight-fluorescent';
case 0x301: return 'day-white-fluorescent';
case 0x302: return 'white-fluorescent';
case 0x303: return 'warm-white-fluorescent';
case 0x304: return 'living-room-warm-white-fluorescent';
case 0x400: return 'incandescent';
case 0x500: return 'flash';
case 0x600: return 'underwater';
case 0xf00: return 'custom';
case 0xf01: return 'custom-2';
case 0xf02: return 'custom-3';
case 0xf03: return 'custom-4';
case 0xf04: return 'custom-5';
case 0xff0: return 'kelvin';
default: return 'auto';
}
};
export const processWhiteBalanceComponent = (value: number) => value / 20;
export const getFujifilmRecipeFromMakerNote = (
bytes: Buffer,
): FujifilmRecipe => {
const recipe: FujifilmRecipe = {
dynamicRange: DEFAULT_DYNAMIC_RANGE,
whiteBalance: DEFAULT_WHITE_BALANCE,
grainEffect: DEFAULT_GRAIN_EFFECT,
};
parseFujifilmMakerNote(
bytes,
(tag, numbers) => {
switch (tag) {
case TAG_ID_DYNAMIC_RANGE:
recipe.dynamicRange.range = numbers[0] === 3
? 'wide'
: 'standard';
break;
case TAG_ID_DYNAMIC_RANGE_SETTING:
recipe.dynamicRange.setting = processDynamicRangeSettings(numbers[0]);
break;
case TAG_ID_DEVELOPMENT_DYNAMIC_RANGE:
recipe.dynamicRange.development = numbers[0];
break;
case TAG_ID_WHITE_BALANCE:
recipe.whiteBalance.type = processWhiteBalanceType(numbers[0]);
break;
case TAG_ID_WHITE_BALANCE_FINE_TUNE:
recipe.whiteBalance.red = processWhiteBalanceComponent(numbers[0]);
recipe.whiteBalance.blue = processWhiteBalanceComponent(numbers[1]);
break;
case TAG_ID_NOISE_REDUCTION:
recipe.highISONoiseReduction = processNoiseReduction(numbers[0]);
break;
case TAG_ID_NOISE_REDUCTION_BASIC:
recipe.noiseReductionBasic = processNoiseReductionLegacy(numbers[0]);
break;
case TAG_ID_HIGHLIGHT:
recipe.highlight = processTone(numbers[0]);
break;
case TAG_ID_SHADOW:
recipe.shadow = processTone(numbers[0]);
break;
case TAG_ID_SATURATION:
recipe.color = processSaturation(numbers[0]);
break;
case TAG_ID_SHARPNESS:
recipe.sharpness = processSharpness(numbers[0]);
break;
case TAG_ID_CLARITY:
recipe.clarity = processClarity(numbers[0]);
break;
case TAG_ID_COLOR_CHROME_EFFECT:
recipe.colorChromeEffect = processWeakStrong(numbers[0]);
break;
case TAG_ID_COLOR_CHROME_FX_BLUE:
recipe.colorChromeFXBlue = processWeakStrong(numbers[0]);
break;
case TAG_ID_GRAIN_EFFECT_ROUGHNESS:
recipe.grainEffect.roughness = processWeakStrong(numbers[0]);
break;
case TAG_ID_GRAIN_EFFECT_SIZE:
recipe.grainEffect.size = processGrainEffectSize(numbers[0]);
break;
case TAG_ID_BW_ADJUSTMENT:
recipe.bwAdjustment = numbers[0];
break;
case TAG_ID_BW_MAGENTA_GREEN:
recipe.bwMagentaGreen = numbers[0];
break;
}
},
);
return recipe;
};

View File

@ -1,15 +1,4 @@
// MakerNote tag IDs and values referenced from:
// github.com/exiftool/exiftool/blob/master/lib/Image/ExifTool/FujiFilm.pm
import type { ExifData } from 'ts-exif-parser';
export const MAKE_FUJIFILM = 'FUJIFILM';
const BYTE_INDEX_TAG_COUNT = 12;
const BYTE_INDEX_FIRST_TAG = 14;
const BYTES_PER_TAG = 12;
const BYTE_OFFSET_TAG_TYPE = 2;
const BYTE_OFFSET_TAG_VALUE = 8;
import { parseFujifilmMakerNote } from '.';
const TAG_ID_SATURATION = 0x1003;
const TAG_ID_FILM_MODE = 0x1401;
@ -46,9 +35,6 @@ export type FujifilmSimulation =
FujifilmSimulationFromSaturation |
FujifilmMode;
export const isExifForFujifilm = (data: ExifData) =>
data.tags?.Make === MAKE_FUJIFILM;
const getFujifilmSimulationFromSaturation = (
value?: number,
): FujifilmSimulationFromSaturation | undefined => {
@ -231,36 +217,6 @@ export const FILM_SIMULATION_FORM_INPUT_OPTIONS = Object
export const labelForFilmSimulation = (simulation: FujifilmSimulation) =>
FILM_SIMULATION_LABELS[simulation];
const parseFujifilmMakerNote = (
bytes: Buffer,
valueForTagUInt: (tagId: number, value: number) => void,
) => {
const tagCount = bytes.readUint16LE(BYTE_INDEX_TAG_COUNT);
for (let i = 0; i < tagCount; i++) {
const index = BYTE_INDEX_FIRST_TAG + i * BYTES_PER_TAG;
if (index + BYTES_PER_TAG < bytes.length) {
const tagId = bytes.readUInt16LE(index);
const tagType = bytes.readUInt16LE(index + BYTE_OFFSET_TAG_TYPE);
switch (tagType) {
// UInt16
case 3:
valueForTagUInt(
tagId,
bytes.readUInt16LE(index + BYTE_OFFSET_TAG_VALUE),
);
break;
// UInt32
case 4:
valueForTagUInt(
tagId,
bytes.readUInt32LE(index + BYTE_OFFSET_TAG_VALUE),
);
break;
}
}
}
};
export const getFujifilmSimulationFromMakerNote = (
bytes: Buffer,
): FujifilmSimulation | undefined => {
@ -269,13 +225,15 @@ export const getFujifilmSimulationFromMakerNote = (
parseFujifilmMakerNote(
bytes,
(tag, value) => {
(tag, numbers) => {
switch (tag) {
case TAG_ID_SATURATION:
filmModeFromSaturation = getFujifilmSimulationFromSaturation(value);
filmModeFromSaturation =
getFujifilmSimulationFromSaturation(numbers[0]);
break;
case TAG_ID_FILM_MODE:
filmMode = getFujifilmMode(value);
filmMode =
getFujifilmMode(numbers[0]);
break;
}
},

View File

@ -1,10 +1,12 @@
import { labelForFilmSimulation } from '@/platforms/fujifilm';
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
import PhotoFilmSimulationIcon from './PhotoFilmSimulationIcon';
import { pathForFilmSimulation } from '@/app/paths';
import { FilmSimulation } from '.';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import EntityLink, {
EntityLinkExternalProps,
} from '@/components/primitives/EntityLink';
import clsx from 'clsx/lite';
export default function PhotoFilmSimulation({
simulation,
@ -16,6 +18,8 @@ export default function PhotoFilmSimulation({
}: {
simulation: FilmSimulation
countOnHover?: number
recipe?: FujifilmRecipe
className?: string
} & EntityLinkExternalProps) {
const { small, medium, large } = labelForFilmSimulation(simulation);
@ -24,7 +28,10 @@ export default function PhotoFilmSimulation({
label={medium}
labelSmall={small}
href={pathForFilmSimulation(simulation)}
icon={<PhotoFilmSimulationIcon simulation={simulation} />}
icon={<PhotoFilmSimulationIcon
simulation={simulation}
className={clsx(contrast === 'frosted' && 'text-black')}
/>}
title={`Film Simulation: ${large}`}
type={type}
badged={badged}

View File

@ -1,5 +1,5 @@
/* eslint-disable max-len */
import { labelForFilmSimulation } from '@/platforms/fujifilm';
import { labelForFilmSimulation } from '@/platforms/fujifilm/simulation';
import { CSSProperties } from 'react';
import { FilmSimulation } from '.';

View File

@ -11,7 +11,7 @@ import {
import {
FujifilmSimulation,
labelForFilmSimulation,
} from '@/platforms/fujifilm';
} from '@/platforms/fujifilm/simulation';
export type FilmSimulation = FujifilmSimulation;

View File

@ -46,6 +46,8 @@ export interface AppStateContext {
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
shouldDebugInsights?: boolean
setShouldDebugInsights?: Dispatch<SetStateAction<boolean>>
shouldDebugRecipeOverlays?: boolean
setShouldDebugRecipeOverlays?: Dispatch<SetStateAction<boolean>>
}
export const AppStateContext = createContext<AppStateContext>({});

View File

@ -65,6 +65,8 @@ export default function AppStateProvider({
useState(false);
const [shouldDebugInsights, setShouldDebugInsights] =
useState(IS_DEVELOPMENT);
const [shouldDebugRecipeOverlays, setShouldDebugRecipeOverlays] =
useState(false);
const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []);
@ -143,6 +145,8 @@ export default function AppStateProvider({
setShouldShowBaselineGrid,
shouldDebugInsights,
setShouldDebugInsights,
shouldDebugRecipeOverlays,
setShouldDebugRecipeOverlays,
}}
>
{children}

20
src/utility/dom.ts Normal file
View File

@ -0,0 +1,20 @@
export const isElementEntirelyInViewport = (
element?: HTMLElement | null,
) => {
if (element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (
window.innerHeight ||
document.documentElement.clientHeight
) &&
rect.right <= (
window.innerWidth || document.documentElement.clientWidth
)
);
} else {
return false;
}
};

View File

@ -4,7 +4,7 @@ const MOUSE_DOWN = 'mousedown';
interface Options {
// HTML reference
htmlElements: RefObject<HTMLElement | null>[],
htmlElements: (RefObject<HTMLElement | null> | undefined)[],
// Callbacks based on click target
onClick?: (event?: MouseEvent) => void,
onClickInside?: (event?: MouseEvent) => void,
@ -24,7 +24,7 @@ const useClickInsideOutside = ({
const target = event.target as HTMLElement;
const htmlElementsContainTarget = htmlElements
.some(element => element.current?.contains(target));
.some(element => element?.current?.contains(target));
// On click
onClick?.(event);

View File

@ -86,14 +86,22 @@
}
}
/* Text Primitives */
@utility text-dark {
@apply text-gray-900
}
@utility text-light {
@apply text-gray-100
}
/* Text */
@utility text-main {
@apply
text-gray-900 dark:text-gray-100
text-dark dark:text-light
}
@utility text-invert {
@apply
text-white dark:text-black
text-light dark:text-dark
}
@utility text-medium {
@apply