Build tooling around server-side blur generation
This commit is contained in:
parent
1114bec462
commit
d448c36445
@ -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."
|
||||
|
||||
@ -35,9 +35,6 @@ const nextConfig = {
|
||||
.concat(createRemotePattern(HOSTNAME_AWS_S3)),
|
||||
minimumCacheTTL: 31536000,
|
||||
},
|
||||
// experimental: {
|
||||
// serverComponentsExternalPackages: ['jimp'],
|
||||
// },
|
||||
};
|
||||
|
||||
const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
}} />
|
||||
|
||||
@ -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),
|
||||
}} />
|
||||
|
||||
@ -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),
|
||||
}} />
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import clsx from 'clsx/lite';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import Spinner from './Spinner';
|
||||
import SiteGrid from './SiteGrid';
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
36
src/photo/UpdateBlurDataButton.tsx
Normal file
36
src/photo/UpdateBlurDataButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -56,7 +56,7 @@ export default function AiButton({
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
disabled={!aiContent.isReady || isLoading}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <Spinner /> : <HiSparkles size={16} />}
|
||||
</button>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -20,6 +20,8 @@ export interface AppStateContext {
|
||||
registerAdminUpdate?: () => void
|
||||
shouldShowBaselineGrid?: boolean
|
||||
setShouldShowBaselineGrid?: Dispatch<SetStateAction<boolean>>
|
||||
shouldDebugBlur?: boolean
|
||||
setShouldDebugBlur?: Dispatch<SetStateAction<boolean>>
|
||||
clearNextPhotoAnimation?: () => void
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user