Build tooling around server-side blur generation

This commit is contained in:
Sam Becker 2024-05-06 00:19:42 -05:00
parent 1114bec462
commit d448c36445
27 changed files with 235 additions and 256 deletions

View File

@ -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."

View File

@ -35,9 +35,6 @@ const nextConfig = {
.concat(createRemotePattern(HOSTNAME_AWS_S3)),
minimumCacheTTL: 31536000,
},
// experimental: {
// serverComponentsExternalPackages: ['jimp'],
// },
};
const withBundleAnalyzer = require('@next/bundle-analyzer')({

View File

@ -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 },
@ -17,11 +19,16 @@ export default async function PhotoEditPage({
const hasAiTextGeneration = AI_TEXT_GENERATION_ENABLED;
const imageThumbnailBase64 = await resizeImageFromUrl(
getNextImageUrlForRequest(photo.url, 640),
);
return (
<PhotoEditPageClient {...{
photo,
uniqueTags,
hasAiTextGeneration,
imageThumbnailBase64,
}} />
);
};

View File

@ -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 (
<>
<img
alt="Blur Debug"
src={blurBase64}
/>
<UploadPageClient {...{
blobId,
photoFormExif,
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
imageThumbnailBase64,
}} />
</>
);
};

View File

@ -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<HTMLCanvasElement | null>(null);
const refImage = useRef(typeof Image !== 'undefined' ? new Image() : null);
const refTimeouts = useRef<NodeJS.Timeout[]>([]);
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 (
<canvas ref={refCanvas} className={hidden ? 'hidden' : undefined} />
);
}

View File

@ -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: <RiToolsFill size={16} className="translate-x-[-1px]" />,
items: [{
label: 'Toggle Blur Debug',
action: () => setShouldDebugBlur?.(prev => !prev),
annotation: shouldDebugBlur ? <FaCheck size={12} /> : undefined,
}, {
label: 'Toggle Baseline Grid',
action: () => setShouldShowBaselineGrid?.(prev => !prev),
annotation: shouldShowBaselineGrid ? <FaCheck size={12} /> : undefined,
}],
});
}

View File

@ -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 &&
<div className={clsx(
'@container',
'absolute inset-0',
'bg-main overflow-hidden',
'transition-opacity duration-300 ease-in',
isLoading ? 'opacity-100' : 'opacity-0',
isLoading || shouldDebugBlur ? 'opacity-100' : 'opacity-0',
)}>
{(BLUR_ENABLED && props.blurDataURL)
? <img {...{
@ -69,8 +75,10 @@ export default function ImageBlurFallback(props: ImageProps) {
src: blurDataURL,
className: clsx(
imageClassName,
// Fix poorly blurred placeholder data generated by Safari
'blur-sm @xs:blue-md scale-105',
// Fix poorly blurred placeholder data generated on client
blurCompatibilityMode
? 'blur-[4px] @xs:blue-md scale-[1.05]'
: 'blur-[2px] @xs:blue-md scale-[1.01]',
),
}} />
: <div className={clsx(

View File

@ -7,6 +7,7 @@ export default function ImageLarge({
alt,
aspectRatio,
blurData,
blurCompatibilityMode,
priority,
}: {
className?: string
@ -14,6 +15,7 @@ export default function ImageLarge({
alt: string
aspectRatio: number
blurData?: string
blurCompatibilityMode?: boolean
priority?: boolean
}) {
return (
@ -21,8 +23,9 @@ export default function ImageLarge({
className,
src,
alt,
priority,
blurDataURL: blurData,
blurCompatibilityMode,
priority,
width: IMAGE_LARGE_WIDTH,
height: Math.round(IMAGE_LARGE_WIDTH / aspectRatio),
}} />

View File

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

View File

@ -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 (
<ImageBlurFallback {...{
@ -20,6 +22,7 @@ export default function ImageTiny({
src,
alt,
blurDataURL: blurData,
blurCompatibilityMode,
width: IMAGE_TINY_WIDTH,
height: Math.round(IMAGE_TINY_WIDTH / aspectRatio),
}} />

View File

@ -1,4 +1,4 @@
import clsx from 'clsx/lite';
import { clsx } from 'clsx/lite';
import Spinner from './Spinner';
import SiteGrid from './SiteGrid';

View File

@ -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 (
<AdminChildPage

View File

@ -3,6 +3,7 @@
import {
Photo,
altTextForPhoto,
doesPhotoNeedBlurCompatibility,
shouldShowCameraDataForPhoto,
shouldShowExifDataForPhoto,
} from '.';
@ -81,6 +82,7 @@ export default function PhotoLarge({
src={photo.url}
aspectRatio={photo.aspectRatio}
blurData={photo.blurData}
blurCompatibilityMode={doesPhotoNeedBlurCompatibility(photo)}
priority={priority}
/>
</Link>}

View File

@ -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}

View File

@ -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)}
/>
</Link>

View File

@ -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 (
<button
type="button"
className={clsx(
'flex min-w-[3.25rem] min-h-9 justify-center',
'h-full',
)}
disabled={!photoUrl || isLoading}
onClick={() => {
if (photoUrl) {
setIsLoading(true);
getImageBlurAction(photoUrl)
.then(blurData => onUpdatedBlurData(blurData))
.finally(() => setIsLoading(false));
}
}}
>
{isLoading ? <Spinner /> : <FiRotateCcw size={18} />}
</button>
);
}

View File

@ -16,12 +16,14 @@ export default function UploadPageClient({
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
imageThumbnailBase64,
}: {
blobId?: string
photoFormExif: Partial<PhotoFormData>
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,

View File

@ -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));

View File

@ -56,7 +56,7 @@ export default function AiButton({
e.preventDefault();
}
}}
disabled={!aiContent.isReady || isLoading}
disabled={isLoading}
>
{isLoading ? <Spinner /> : <HiSparkles size={16} />}
</button>

View File

@ -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<typeof useAiImageQueries>;
export default function useAiImageQueries(
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
imageData: string,
) {
const [imageData, setImageData] = useState<string>();
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,
};
}

View File

@ -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<Partial<PhotoFormData>>(initialPhotoForm);
const [formErrors, setFormErrors] =
useState(getFormErrors(initialPhotoForm));
const [blurError, setBlurError] =
useState<string>();
const [hasBlurData, setHasBlurData] = useState(false);
const { invalidateSwr } = useAppState();
const { invalidateSwr, shouldDebugBlur } = useAppState();
const changedFormKeys = useMemo(() =>
getChangedFormFields(initialPhotoForm, formData),
@ -80,17 +71,6 @@ export default function PhotoForm({
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
useEffect(() => {
@ -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'
? <UpdateBlurDataButton
photoUrl={formData.url}
onUpdatedBlurData={blurData =>
setFormData(data => ({ ...data, blurData }))}
/>
: null;
}
}
};
return (
<div className="space-y-8 max-w-[38rem] relative">
{debugBlur && blurError &&
<div className="border error text-error rounded-md px-2 py-1">
{blurError}
</div>}
<div className="flex gap-2">
<div className="relative">
<ImageBlurFallback
@ -232,13 +206,14 @@ export default function PhotoForm({
'border rounded-md overflow-hidden',
'border-gray-200 dark:border-gray-700',
)}
blurDataURL={formData.blurData}
width={width}
height={height}
priority
/>
<div className={clsx(
'absolute top-2 left-2 transition-opacity duration-500',
showImageLoadingStatus ? 'opacity-100' : 'opacity-0',
aiContent?.isLoading ? 'opacity-100' : 'opacity-0',
)}>
<div className={clsx(
'leading-none text-xs font-medium uppercase tracking-wide',
@ -246,43 +221,20 @@ export default function PhotoForm({
'inline-flex items-center gap-2',
'bg-white/70 dark:bg-black/60 backdrop-blur-md',
'border border-gray-900/10 dark:border-gray-700/70',
'select-none',
)}>
<Spinner
color="text"
size={9}
className={clsx(
'text-extra-dim',
'translate-x-[1px] translate-y-[0.5px]'
'translate-x-[1px] translate-y-[0.5px]',
)}
/>
Analyzing image
</div>
</div>
</div>
<CanvasBlurCapture
imageUrl={getNextImageUrlForRequest(
url,
640,
undefined,
typeof window !== 'undefined' ? window.location.origin : undefined,
)}
width={width}
height={height}
onLoad={aiContent?.setImageData}
onCapture={updateBlurData}
onError={setBlurError}
/>
{debugBlur && formData.blurData &&
<img
alt="blur"
src={formData.blurData}
className={clsx(
'border rounded-md overflow-hidden',
'border-gray-200 dark:border-gray-700'
)}
width={width}
height={height}
/>}
</div>
<form
action={type === 'create' ? createPhotoAction : updatePhotoAction}
@ -322,7 +274,9 @@ export default function PhotoForm({
<FieldSetWithStatus
key={key}
id={key}
label={label}
label={label + (key === 'blurData' && shouldDebugBlur
? ` (${(formData[key] ?? '').length} chars.)`
: '')}
note={note}
error={formErrors[key]}
value={formData[key] ?? ''}
@ -357,7 +311,7 @@ export default function PhotoForm({
(loadingMessage && !formData[key] ? true : false) ||
isFieldGeneratingAi(key)}
type={type}
accessory={aiButtonForField(key)}
accessory={accessoryForField(key)}
/>)}
</div>
{/* Actions */}

View File

@ -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;
}
});

View File

@ -6,16 +6,21 @@ import { AiAutoGeneratedField } from '../ai';
export default function usePhotoFormParent({
photoForm,
textFieldsToAutoGenerate,
imageThumbnailBase64,
}: {
photoForm?: Partial<PhotoFormData>,
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
} = {}) {
photoForm?: Partial<PhotoFormData>
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,

View File

@ -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'));

View File

@ -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<PhotoFormData>
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));

View File

@ -20,6 +20,8 @@ export interface AppStateContext {
registerAdminUpdate?: () => void
shouldShowBaselineGrid?: boolean
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
shouldDebugBlur?: boolean
setShouldDebugBlur?: Dispatch<SetStateAction<boolean>>
clearNextPhotoAnimation?: () => void
}

View File

@ -29,6 +29,8 @@ export default function AppStateProvider({
const [adminUpdateTimes, setAdminUpdateTimes] = useState<Date[]>([]);
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),
}}