{(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}
-
}
-
- {debugBlur && formData.blurData &&
-

}
)}
{/* 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),
}}