diff --git a/README.md b/README.md index 16692993..eacb861c 100644 --- a/README.md +++ b/README.md @@ -219,3 +219,6 @@ FAQ #### 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. + +#### Why does my image placeholder blur look different from photo to photo? +> Earlier template versions generated blur data on the client, which varied visually from browser to browser. Data is now generated consistently on the server. If you wish to update blur data for a particular photo: go to the photo editor, click the refresh icon next to the "Blur Data" field, and click "Update." diff --git a/next.config.js b/next.config.js index 51aab061..f0ff1b30 100644 --- a/next.config.js +++ b/next.config.js @@ -35,9 +35,6 @@ const nextConfig = { .concat(createRemotePattern(HOSTNAME_AWS_S3)), minimumCacheTTL: 31536000, }, - // experimental: { - // serverComponentsExternalPackages: ['jimp'], - // }, }; const withBundleAnalyzer = require('@next/bundle-analyzer')({ diff --git a/src/app/admin/photos/[photoId]/edit/page.tsx b/src/app/admin/photos/[photoId]/edit/page.tsx index c9a5c68c..4ca6dcb6 100644 --- a/src/app/admin/photos/[photoId]/edit/page.tsx +++ b/src/app/admin/photos/[photoId]/edit/page.tsx @@ -3,6 +3,8 @@ import { getPhotoNoStore, getUniqueTagsCached } from '@/photo/cache'; import { PATH_ADMIN } from '@/site/paths'; import PhotoEditPageClient from '@/photo/PhotoEditPageClient'; import { AI_TEXT_GENERATION_ENABLED } from '@/site/config'; +import { resizeImageFromUrl } from '@/photo/server'; +import { getNextImageUrlForRequest } from '@/services/next-image'; export default async function PhotoEditPage({ params: { photoId }, @@ -16,12 +18,17 @@ export default async function PhotoEditPage({ const uniqueTags = await getUniqueTagsCached(); const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED; + + const imageThumbnailBase64 = await resizeImageFromUrl( + getNextImageUrlForRequest(photo.url, 640), + ); return ( ); }; diff --git a/src/app/admin/uploads/[uploadPath]/page.tsx b/src/app/admin/uploads/[uploadPath]/page.tsx index ec9c2f16..d52d7a10 100644 --- a/src/app/admin/uploads/[uploadPath]/page.tsx +++ b/src/app/admin/uploads/[uploadPath]/page.tsx @@ -1,5 +1,5 @@ import { PATH_ADMIN } from '@/site/paths'; -import { blurImage, extractExifDataFromBlobPath } from '@/photo/server'; +import { extractImageDataFromBlobPath } from '@/photo/server'; import { redirect } from 'next/navigation'; import { getUniqueTagsCached } from '@/photo/cache'; import UploadPageClient from '@/photo/UploadPageClient'; @@ -16,11 +16,14 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { const { blobId, photoFormExif, - } = await extractExifDataFromBlobPath(uploadPath, true); + imageResizedBase64: imageThumbnailBase64, + } = await extractImageDataFromBlobPath( uploadPath, { + includeInitialPhotoFields: true, + generateBlurData: true, + generateResizedImage: true, + }); - const blurBase64 = await blurImage(uploadPath); - - if (!photoFormExif) { redirect(PATH_ADMIN); } + if (!photoFormExif || !imageThumbnailBase64) { redirect(PATH_ADMIN); } const uniqueTags = await getUniqueTagsCached(); @@ -29,18 +32,13 @@ export default async function UploadPage({ params: { uploadPath } }: Params) { const textFieldsToAutoGenerate = AI_TEXT_AUTO_GENERATED_FIELDS; return ( - <> - Blur Debug - - + ); }; diff --git a/src/components/CanvasBlurCapture.tsx b/src/components/CanvasBlurCapture.tsx deleted file mode 100644 index cce1ed31..00000000 --- a/src/components/CanvasBlurCapture.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client'; - -import { useEffect, useRef } from 'react'; - -const RETRY_DELAY = 2000; - -export default function CanvasBlurCapture({ - imageUrl, - onLoad, - onCapture, - onError, - width, - height, - hidden = true, - edgeCompensation = 10, - scale = 0.5, - quality = 0.9, -}: { - imageUrl: string - onLoad?: (imageData: string) => void - onCapture: (imageData: string) => void - onError?: (error: string) => void - width: number - height: number - hidden?: boolean - edgeCompensation?: number - scale?: number - quality?: number -}) { - const refCanvas = useRef(null); - const refImage = useRef(typeof Image !== 'undefined' ? new Image() : null); - const refTimeouts = useRef([]); - const refShouldCapture = useRef(true); - - useEffect(() => { - refShouldCapture.current = true; - - const capture = () => { - if (refShouldCapture.current) { - if ( - refCanvas.current && - refImage.current?.complete - ) { - const canvas = refCanvas.current; - canvas.width = width * scale; - canvas.height = height * scale; - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - const context = refCanvas.current?.getContext('2d'); - if (context) { - // Draw scaled image - context.scale(scale, scale); - context.drawImage( - refImage.current, - -edgeCompensation, - -edgeCompensation, - width + edgeCompensation * 2, - width * refImage.current.height / refImage.current.width + - edgeCompensation * 2, - ); - onLoad?.(canvas.toDataURL('image/jpeg', quality)); - // Draw blurred image - context.filter = - 'contrast(1.2) saturate(1.2) ' + - `blur(${scale * 10}px)`; - context.drawImage( - refImage.current, - -edgeCompensation, - -edgeCompensation, - width + edgeCompensation * 2, - width * refImage.current.height / refImage.current.width + - edgeCompensation * 2, - ); - onCapture(canvas.toDataURL('image/jpeg', quality)); - onError?.(''); - refTimeouts.current.forEach(clearTimeout); - refShouldCapture.current = false; - } else { - console.error('Cannot get 2d context ... retrying'); - onError?.('Cannot get 2d context ... retrying'); - // Retry capture in case canvas is not available - refTimeouts.current.push(setTimeout(capture, RETRY_DELAY)); - } - } else { - // eslint-disable-next-line max-len - console.error('Cannot generate blur data: canvas/image not ready ... retrying'); - // eslint-disable-next-line max-len - onError?.('Cannot generate blur data: canvas/image not ready ... retrying'); - // Retry capture in case canvas is not available - refTimeouts.current.push(setTimeout(capture, RETRY_DELAY)); - } - } - }; - - if (refImage.current) { - refImage.current.crossOrigin = 'anonymous'; - refImage.current.src = imageUrl; - refImage.current.onload = capture; - } - - // Attempt delayed capture in case image.onload never fires - refTimeouts.current.push(setTimeout(capture, RETRY_DELAY)); - - // Store timeout ref to ensure it's closed over - // in cleanup function (recommended by exhaustive-deps) - const timeouts = refTimeouts.current; - return () => { - refShouldCapture.current = false; - timeouts.forEach(clearTimeout); - }; - }, [ - imageUrl, - onCapture, - onLoad, - onError, - width, - height, - edgeCompensation, - scale, - quality, - ]); - - return ( - - ); -} diff --git a/src/components/CommandKClient.tsx b/src/components/CommandKClient.tsx index 5286062a..7c566112 100644 --- a/src/components/CommandKClient.tsx +++ b/src/components/CommandKClient.tsx @@ -36,6 +36,7 @@ import { TbPhoto } from 'react-icons/tb'; import { getKeywordsForPhoto, titleForPhoto } from '@/photo'; import PhotoDate from '@/photo/PhotoDate'; import PhotoTiny from '@/photo/PhotoTiny'; +import { FaCheck } from 'react-icons/fa6'; const LISTENER_KEYDOWN = 'keydown'; const MINIMUM_QUERY_LENGTH = 2; @@ -69,9 +70,12 @@ export default function CommandKClient({ isUserSignedIn, setUserEmail, isCommandKOpen: isOpen, + shouldShowBaselineGrid, + shouldDebugBlur, setIsCommandKOpen: setIsOpen, setShouldRespondToKeyboardCommands, setShouldShowBaselineGrid, + setShouldDebugBlur, } = useAppState(); const isOpenRef = useRef(isOpen); @@ -193,8 +197,13 @@ export default function CommandKClient({ heading: 'Debug Tools', accessory: , items: [{ + label: 'Toggle Blur Debug', + action: () => setShouldDebugBlur?.(prev => !prev), + annotation: shouldDebugBlur ? : undefined, + }, { label: 'Toggle Baseline Grid', action: () => setShouldShowBaselineGrid?.(prev => !prev), + annotation: shouldShowBaselineGrid ? : undefined, }], }); } diff --git a/src/components/ImageBlurFallback.tsx b/src/components/ImageBlurFallback.tsx index 6f5072b7..beffe029 100644 --- a/src/components/ImageBlurFallback.tsx +++ b/src/components/ImageBlurFallback.tsx @@ -2,18 +2,24 @@ /* eslint-disable jsx-a11y/alt-text */ import { BLUR_ENABLED } from '@/site/config'; +import { useAppState } from '@/state/AppState'; import { clsx} from 'clsx/lite'; import Image, { ImageProps } from 'next/image'; import { useCallback, useEffect, useRef, useState } from 'react'; -export default function ImageBlurFallback(props: ImageProps) { +export default function ImageBlurFallback(props: ImageProps & { + blurCompatibilityMode?: boolean +}) { const { className, priority, blurDataURL, + blurCompatibilityMode, ...rest } = props; + const { shouldDebugBlur } = useAppState(); + const [wasCached, setWasCached] = useState(true); const [isLoading, setIsLoading] = useState(true); const [didError, setDidError] = useState(false); @@ -55,13 +61,13 @@ export default function ImageBlurFallback(props: ImageProps) { 'flex relative', )} > - {showPlaceholder && + {showPlaceholder || shouldDebugBlur &&
{(BLUR_ENABLED && props.blurDataURL) ? :
diff --git a/src/components/ImageSmall.tsx b/src/components/ImageSmall.tsx index d13cb0e7..830a767a 100644 --- a/src/components/ImageSmall.tsx +++ b/src/components/ImageSmall.tsx @@ -7,6 +7,7 @@ export default function ImageSmall({ alt, aspectRatio, blurData, + blurCompatibilityMode, priority, }: { className?: string @@ -14,6 +15,7 @@ export default function ImageSmall({ alt: string aspectRatio: number blurData?: string + blurCompatibilityMode?: boolean priority?: boolean }) { return ( @@ -21,8 +23,9 @@ export default function ImageSmall({ className, src, alt, - priority, blurDataURL: blurData, + blurCompatibilityMode, + priority, width: IMAGE_SMALL_WIDTH, height: Math.round(IMAGE_SMALL_WIDTH / aspectRatio), }} /> diff --git a/src/components/ImageTiny.tsx b/src/components/ImageTiny.tsx index 5e976775..cf57eda8 100644 --- a/src/components/ImageTiny.tsx +++ b/src/components/ImageTiny.tsx @@ -7,12 +7,14 @@ export default function ImageTiny({ alt, aspectRatio, blurData, + blurCompatibilityMode, }: { className?: string src: string alt: string aspectRatio: number blurData?: string + blurCompatibilityMode?: boolean }) { return ( diff --git a/src/components/PageSpinner.tsx b/src/components/PageSpinner.tsx index cd4c99af..630f5821 100644 --- a/src/components/PageSpinner.tsx +++ b/src/components/PageSpinner.tsx @@ -1,4 +1,4 @@ -import clsx from 'clsx/lite'; +import { clsx } from 'clsx/lite'; import Spinner from './Spinner'; import SiteGrid from './SiteGrid'; diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx index 218d35a4..94561417 100644 --- a/src/photo/PhotoEditPageClient.tsx +++ b/src/photo/PhotoEditPageClient.tsx @@ -21,10 +21,12 @@ export default function PhotoEditPageClient({ photo, uniqueTags, hasAiTextGeneration, + imageThumbnailBase64, }: { photo: Photo uniqueTags: TagsWithMeta hasAiTextGeneration: boolean + imageThumbnailBase64: string }) { const seedExifData = { url: photo.url }; @@ -48,7 +50,10 @@ export default function PhotoEditPageClient({ hasTextContent, setHasTextContent, aiContent, - } = usePhotoFormParent({ photoForm }); + } = usePhotoFormParent({ + photoForm, + imageThumbnailBase64, + }); return ( } diff --git a/src/photo/PhotoSmall.tsx b/src/photo/PhotoSmall.tsx index d8d0a7b6..a379d2cc 100644 --- a/src/photo/PhotoSmall.tsx +++ b/src/photo/PhotoSmall.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Photo, altTextForPhoto } from '.'; +import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.'; import ImageSmall from '@/components/ImageSmall'; import Link from 'next/link'; import { clsx } from 'clsx/lite'; @@ -49,6 +49,7 @@ export default function PhotoSmall({ src={photo.url} aspectRatio={photo.aspectRatio} blurData={photo.blurData} + blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)} className="w-full" alt={altTextForPhoto(photo)} priority={priority} diff --git a/src/photo/PhotoTiny.tsx b/src/photo/PhotoTiny.tsx index 5cb9b9c3..797d8ccb 100644 --- a/src/photo/PhotoTiny.tsx +++ b/src/photo/PhotoTiny.tsx @@ -1,4 +1,4 @@ -import { Photo, altTextForPhoto } from '.'; +import { Photo, altTextForPhoto, doesPhotoNeedBlurCompatibility } from '.'; import ImageTiny from '@/components/ImageTiny'; import Link from 'next/link'; import { clsx } from 'clsx/lite'; @@ -44,6 +44,7 @@ export default function PhotoTiny({ src={photo.url} aspectRatio={photo.aspectRatio} blurData={photo.blurData} + blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)} alt={altTextForPhoto(photo)} /> diff --git a/src/photo/UpdateBlurDataButton.tsx b/src/photo/UpdateBlurDataButton.tsx new file mode 100644 index 00000000..104baa39 --- /dev/null +++ b/src/photo/UpdateBlurDataButton.tsx @@ -0,0 +1,36 @@ +import { clsx } from 'clsx/lite'; +import { FiRotateCcw } from 'react-icons/fi'; +import { getImageBlurAction } from './actions'; +import { useState } from 'react'; +import Spinner from '@/components/Spinner'; + +export default function UpdateBlurDataButton({ + photoUrl, + onUpdatedBlurData, +}: { + photoUrl?: string + onUpdatedBlurData: (blurData: string) => void +}) { + const [isLoading, setIsLoading] = useState(false); + + return ( + + ); +} diff --git a/src/photo/UploadPageClient.tsx b/src/photo/UploadPageClient.tsx index 3a070d55..912b840f 100644 --- a/src/photo/UploadPageClient.tsx +++ b/src/photo/UploadPageClient.tsx @@ -16,12 +16,14 @@ export default function UploadPageClient({ uniqueTags, hasAiTextGeneration, textFieldsToAutoGenerate, + imageThumbnailBase64, }: { blobId?: string photoFormExif: Partial uniqueTags: TagsWithMeta hasAiTextGeneration?: boolean textFieldsToAutoGenerate?: AiAutoGeneratedField[], + imageThumbnailBase64: string }) { const { pending, @@ -31,7 +33,10 @@ export default function UploadPageClient({ hasTextContent, setHasTextContent, aiContent, - } = usePhotoFormParent({ textFieldsToAutoGenerate }); + } = usePhotoFormParent({ + textFieldsToAutoGenerate, + imageThumbnailBase64, + }); const initialPhotoForm = useMemo(() => ({ ...photoFormExif, diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 21dcc7f4..58caab53 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -33,7 +33,7 @@ import { PATH_ROOT, pathForPhoto, } from '@/site/paths'; -import { extractExifDataFromBlobPath } from './server'; +import { blurImageFromUrl, extractImageDataFromBlobPath } from './server'; import { TAG_FAVS, isTagFavs } from '@/tag'; import { convertPhotoToPhotoDbInsert } from '.'; import { safelyRunAdminServerAction } from '@/auth'; @@ -154,7 +154,10 @@ export async function getExifDataAction( return safelyRunAdminServerAction(async () => { const { url } = photoFormPrevious; if (url) { - const { photoFormExif } = await extractExifDataFromBlobPath(url); + const { photoFormExif } = await extractImageDataFromBlobPath( + url, { + generateBlurData: true, + }); if (photoFormExif) { return photoFormExif; } @@ -169,7 +172,10 @@ export async function syncPhotoExifDataAction(formData: FormData) { if (photoId) { const photo = await getPhoto(photoId); if (photo) { - const { photoFormExif } = await extractExifDataFromBlobPath(photo.url); + const { photoFormExif } = await extractImageDataFromBlobPath( + photo.url, { + generateBlurData: true, + }); if (photoFormExif) { const photoFormDbInsert = convertFormDataToPhotoDbInsert({ ...convertPhotoToFormData(photo), @@ -209,6 +215,9 @@ export const getPhotosAction = async ( ) => getPhotos({ offset, includeHidden, limit }); +export const getImageBlurAction = async (url: string) => + blurImageFromUrl(url); + export const queryPhotosByTitleAction = async (query: string) => (await getPhotos({ query, limit: 10 })) .filter(({ title }) => Boolean(title)); diff --git a/src/photo/ai/AiButton.tsx b/src/photo/ai/AiButton.tsx index b88d1fb0..f7fbdbe7 100644 --- a/src/photo/ai/AiButton.tsx +++ b/src/photo/ai/AiButton.tsx @@ -56,7 +56,7 @@ export default function AiButton({ e.preventDefault(); } }} - disabled={!aiContent.isReady || isLoading} + disabled={isLoading} > {isLoading ? : } diff --git a/src/photo/ai/useAiImageQueries.ts b/src/photo/ai/useAiImageQueries.ts index 3071dc64..618af005 100644 --- a/src/photo/ai/useAiImageQueries.ts +++ b/src/photo/ai/useAiImageQueries.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import useAiImageQuery from './useAiImageQuery'; import useTitleCaptionAiImageQuery from './useTitleCaptionAiImageQuery'; import { ALL_AI_AUTO_GENERATED_FIELDS, AiAutoGeneratedField } from '.'; @@ -7,11 +7,8 @@ export type AiContent = ReturnType; export default function useAiImageQueries( textFieldsToAutoGenerate: AiAutoGeneratedField[] = [], + imageData: string, ) { - const [imageData, setImageData] = useState(); - - const isReady = Boolean(imageData); - const [ requestTitleCaption, _title, @@ -115,12 +112,10 @@ export default function useAiImageQueries( caption, tags, semanticDescription, - isReady, isLoading, isLoadingTitle, isLoadingCaption, isLoadingTags, isLoadingSemantic, - setImageData, }; } diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx index a354e49a..f1b81c2e 100644 --- a/src/photo/form/PhotoForm.tsx +++ b/src/photo/form/PhotoForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { FORM_METADATA_ENTRIES, PhotoFormData, @@ -15,21 +15,18 @@ import { createPhotoAction, updatePhotoAction } from '../actions'; import SubmitButtonWithStatus from '@/components/SubmitButtonWithStatus'; import Link from 'next/link'; import { clsx } from 'clsx/lite'; -import CanvasBlurCapture from '@/components/CanvasBlurCapture'; import { PATH_ADMIN_PHOTOS, PATH_ADMIN_UPLOADS } from '@/site/paths'; import { toastSuccess, toastWarning } from '@/toast'; import { getDimensionsFromSize } from '@/utility/size'; import ImageBlurFallback from '@/components/ImageBlurFallback'; -import { BLUR_ENABLED } from '@/site/config'; import { TagsWithMeta, sortTagsObjectWithoutFavs } from '@/tag'; import { formatCount, formatCountDescriptive } from '@/utility/string'; import { AiContent } from '../ai/useAiImageQueries'; import AiButton from '../ai/AiButton'; import Spinner from '@/components/Spinner'; -import { getNextImageUrlForRequest } from '@/services/next-image'; -import useDelay from '@/utility/useDelay'; import usePreventNavigation from '@/utility/usePreventNavigation'; import { useAppState } from '@/state/AppState'; +import UpdateBlurDataButton from '../UpdateBlurDataButton'; const THUMBNAIL_SIZE = 300; @@ -39,7 +36,6 @@ export default function PhotoForm({ type = 'create', uniqueTags, aiContent, - debugBlur, onTitleChange, onTextContentChange, onFormStatusChange, @@ -49,8 +45,6 @@ export default function PhotoForm({ type?: 'create' | 'edit' uniqueTags?: TagsWithMeta aiContent?: AiContent - setImageData?: (imageData: string) => void - debugBlur?: boolean onTitleChange?: (updatedTitle: string) => void onTextContentChange?: (hasContent: boolean) => void, onFormStatusChange?: (pending: boolean) => void @@ -59,11 +53,8 @@ export default function PhotoForm({ useState>(initialPhotoForm); const [formErrors, setFormErrors] = useState(getFormErrors(initialPhotoForm)); - const [blurError, setBlurError] = - useState(); - const [hasBlurData, setHasBlurData] = useState(false); - const { invalidateSwr } = useAppState(); + const { invalidateSwr, shouldDebugBlur } = useAppState(); const changedFormKeys = useMemo(() => getChangedFormFields(initialPhotoForm, formData), @@ -79,17 +70,6 @@ export default function PhotoForm({ (type === 'create' || formHasChanged) && isFormValid(formData) && !aiContent?.isLoading; - - const didLoad1000msAgo = useDelay(1000); - - // Show image loading status when necessary for - // blur data or AI analysis - const showImageLoadingStatus = - !hasBlurData && - didLoad1000msAgo && ( - (BLUR_ENABLED && !formData.blurData) || - aiContent !== undefined - ); // Update form when EXIF data // is refreshed by parent @@ -130,16 +110,6 @@ export default function PhotoForm({ const url = formData.url ?? ''; - const updateBlurData = useCallback((blurData: string) => { - if (BLUR_ENABLED) { - setFormData(data => ({ - ...data, - blurData, - })); - } - setHasBlurData(true); - }, []); - useEffect(() => setFormData(data => aiContent?.title ? { ...data, title: aiContent?.title } @@ -183,7 +153,7 @@ export default function PhotoForm({ } }; - const aiButtonForField = (key: keyof PhotoFormData) => { + const accessoryForField = (key: keyof PhotoFormData) => { if (aiContent) { switch (key) { case 'title': @@ -213,16 +183,20 @@ export default function PhotoForm({ requestFields={['semantic']} shouldConfirm={Boolean(formData.semanticDescription)} />; + case 'blurData': + return type === 'edit' + ? + setFormData(data => ({ ...data, blurData }))} + /> + : null; } } }; return (
- {debugBlur && blurError && -
- {blurError} -
}
Analyzing image
- - {debugBlur && formData.blurData && - blur}
)}
{/* Actions */} diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 5c772333..afed10fc 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -42,7 +42,7 @@ type FormMeta = { label: string note?: string required?: boolean - virtual?: boolean + excludeFromInsert?: boolean readOnly?: boolean validate?: (value?: string) => string | undefined validateStringMaxLength?: number @@ -55,6 +55,7 @@ type FormMeta = { selectOptions?: { value: string, label: string }[] selectOptionsDefaultLabel?: string tagOptions?: AnnotatedTag[] + nullOverride?: boolean }; const STRING_MAX_LENGTH_SHORT = 255; @@ -97,6 +98,7 @@ const FORM_METADATA = ( required: BLUR_ENABLED, hideIfEmpty: !BLUR_ENABLED, loadingMessage: 'Generating blur data ...', + nullOverride: !BLUR_ENABLED, }, url: { label: 'url', readOnly: true }, extension: { label: 'extension', readOnly: true }, @@ -121,7 +123,7 @@ const FORM_METADATA = ( takenAt: { label: 'taken at' }, takenAtNaive: { label: 'taken at (naive)' }, priorityOrder: { label: 'priority order' }, - favorite: { label: 'favorite', type: 'checkbox', virtual: true }, + favorite: { label: 'favorite', type: 'checkbox', excludeFromInsert: true }, hidden: { label: 'hidden', type: 'checkbox' }, }); @@ -242,12 +244,15 @@ export const convertFormDataToPhotoDbInsert = ( // - remove server action ID // - remove empty strings Object.keys(photoForm).forEach(key => { + const meta = FORM_METADATA()[key as keyof PhotoFormData]; if ( key.startsWith('$ACTION_ID_') || (photoForm as any)[key] === '' || - FORM_METADATA()[key as keyof PhotoFormData]?.virtual + meta?.excludeFromInsert ) { delete (photoForm as any)[key]; + } else if (meta?.nullOverride) { + (photoForm as any)[key] = null; } }); diff --git a/src/photo/form/usePhotoFormParent.ts b/src/photo/form/usePhotoFormParent.ts index bf3fe83a..f7766347 100644 --- a/src/photo/form/usePhotoFormParent.ts +++ b/src/photo/form/usePhotoFormParent.ts @@ -6,16 +6,21 @@ import { AiAutoGeneratedField } from '../ai'; export default function usePhotoFormParent({ photoForm, textFieldsToAutoGenerate, + imageThumbnailBase64, }: { - photoForm?: Partial, - textFieldsToAutoGenerate?: AiAutoGeneratedField[], -} = {}) { + photoForm?: Partial + textFieldsToAutoGenerate?: AiAutoGeneratedField[] + imageThumbnailBase64: string, +}) { const [pending, setIsPending] = useState(false); const [updatedTitle, setUpdatedTitle] = useState(''); const [hasTextContent, setHasTextContent] = useState(photoForm ? formHasTextContent(photoForm) : false); - const aiContent = useAiImageQueries(textFieldsToAutoGenerate); + const aiContent = useAiImageQueries( + textFieldsToAutoGenerate, + imageThumbnailBase64, + ); return { pending, diff --git a/src/photo/index.ts b/src/photo/index.ts index 2af0a08f..d93af5b3 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -11,6 +11,7 @@ import { formatFocalLength, } from '@/utility/exif'; import camelcaseKeys from 'camelcase-keys'; +import { isAfter } from 'date-fns'; import type { Metadata } from 'next'; // ROOT PAGE @@ -276,3 +277,6 @@ export const isNextImageReadyBasedOnPhotos = async (photos: Photo[]) => photos.length > 0 && fetch(getNextImageUrlForRequest(photos[0].url, 640)) .then(response => response.ok) .catch(() => false); + +export const doesPhotoNeedBlurCompatibility = (photo: Photo) => + isAfter(photo.updatedAt, new Date('2024-05-07')); diff --git a/src/photo/server.ts b/src/photo/server.ts index f05a6cb7..2f0bc5b9 100644 --- a/src/photo/server.ts +++ b/src/photo/server.ts @@ -10,15 +10,29 @@ import { import { ExifData, ExifParserFactory } from 'ts-exif-parser'; import { PhotoFormData } from './form'; import { FilmSimulation } from '@/simulation'; -import sharp from 'sharp'; +import sharp, { Sharp } from 'sharp'; -export const extractExifDataFromBlobPath = async ( +const IMAGE_WIDTH_RESIZE = 200; +const IMAGE_WIDTH_BLUR = 200; + +export const extractImageDataFromBlobPath = async ( blobPath: string, - includeInitialPhotoFields?: boolean, + options?: { + includeInitialPhotoFields?: boolean + generateBlurData?: boolean + generateResizedImage?: boolean + }, ): Promise<{ blobId?: string photoFormExif?: Partial + imageResizedBase64?: string }> => { + const { + includeInitialPhotoFields, + generateBlurData, + generateResizedImage, + } = options ?? {}; + const url = decodeURIComponent(blobPath); const blobId = getIdFromStorageUrl(url); @@ -26,12 +40,13 @@ export const extractExifDataFromBlobPath = async ( const extension = getExtensionFromStorageUrl(url); const fileBytes = blobPath - ? await fetch(url) - .then(res => res.arrayBuffer()) + ? await fetch(url).then(res => res.arrayBuffer()) : undefined; let exifData: ExifData | undefined; let filmSimulation: FilmSimulation | undefined; + let blurData: string | undefined; + let imageResizedBase64: string | undefined; if (fileBytes) { const parser = ExifParserFactory.create(Buffer.from(fileBytes)); @@ -51,6 +66,14 @@ export const extractExifDataFromBlobPath = async ( filmSimulation = getFujifilmSimulationFromMakerNote(makerNote); } } + + if (generateBlurData) { + blurData = await blurImage(fileBytes); + } + + if (generateResizedImage) { + imageResizedBase64 = await resizeImage(fileBytes); + } } return { @@ -63,18 +86,41 @@ export const extractExifDataFromBlobPath = async ( extension, url, }, + ...generateBlurData && { blurData }, ...convertExifToFormData(exifData, filmSimulation), }, }, + imageResizedBase64, }; }; -export const blurImage = async (url: string) => { - const image = await fetch(decodeURIComponent(url)) - .then(res => res.arrayBuffer()); - return sharp(image) - .resize(200) - .blur(20) +const generateBase64 = async ( + image: ArrayBuffer, + middleware: (sharp: Sharp) => Sharp, +) => + middleware(sharp(image)) + .toFormat('jpeg', { quality: 90 }) .toBuffer() - .then(data => `data:image/png;base64,${data.toString('base64')}`); -}; + .then(data => `data:image/jpeg;base64,${data.toString('base64')}`); + +const resizeImage = async (image: ArrayBuffer) => + generateBase64(image, sharp => sharp + .resize(IMAGE_WIDTH_RESIZE) + ); + +const blurImage = async (image: ArrayBuffer) => + generateBase64(image, sharp => sharp + .resize(IMAGE_WIDTH_BLUR) + .modulate({ saturation: 1.15 }) + .blur(4) + ); + +export const resizeImageFromUrl = async (url: string) => + fetch(decodeURIComponent(url)) + .then(res => res.arrayBuffer()) + .then(buffer => resizeImage(buffer)); + +export const blurImageFromUrl = async (url: string) => + fetch(decodeURIComponent(url)) + .then(res => res.arrayBuffer()) + .then(buffer => blurImage(buffer)); diff --git a/src/state/AppState.ts b/src/state/AppState.ts index 94d90b49..83f3ceb4 100644 --- a/src/state/AppState.ts +++ b/src/state/AppState.ts @@ -20,6 +20,8 @@ export interface AppStateContext { registerAdminUpdate?: () => void shouldShowBaselineGrid?: boolean setShouldShowBaselineGrid?: Dispatch> + shouldDebugBlur?: boolean + setShouldDebugBlur?: Dispatch> clearNextPhotoAnimation?: () => void } diff --git a/src/state/AppStateProvider.tsx b/src/state/AppStateProvider.tsx index ac4ecf5b..57c24a14 100644 --- a/src/state/AppStateProvider.tsx +++ b/src/state/AppStateProvider.tsx @@ -29,6 +29,8 @@ export default function AppStateProvider({ const [adminUpdateTimes, setAdminUpdateTimes] = useState([]); const [shouldShowBaselineGrid, setShouldShowBaselineGrid] = useState(false); + const [shouldDebugBlur, setShouldDebugBlur] = + useState(false); const invalidateSwr = useCallback(() => setSwrTimestamp(Date.now()), []); @@ -63,6 +65,8 @@ export default function AppStateProvider({ adminUpdateTimes, registerAdminUpdate, shouldShowBaselineGrid, + shouldDebugBlur, + setShouldDebugBlur, setShouldShowBaselineGrid, clearNextPhotoAnimation: () => setNextPhotoAnimation?.(undefined), }}