From 31396b83ccacfa024d025489012616405ca67200 Mon Sep 17 00:00:00 2001 From: Sam Becker Date: Mon, 27 May 2024 00:16:09 -0500 Subject: [PATCH] Finalize multi-image upload backend data processing --- src/admin/AdminAddAllUploads.tsx | 13 ++-- src/components/FieldSetWithStatus.tsx | 6 ++ src/photo/actions.ts | 51 ++++++++++---- src/photo/ai/index.ts | 8 +++ src/photo/ai/server.ts | 77 +++++++++++++++++++++ src/photo/ai/useAiImageQueries.ts | 16 ++--- src/photo/ai/useAiImageQuery.ts | 9 ++- src/photo/ai/useTitleCaptionAiImageQuery.ts | 2 +- src/photo/form/index.ts | 13 ++-- src/photo/index.ts | 2 + src/services/openai.ts | 2 +- 11 files changed, 156 insertions(+), 43 deletions(-) create mode 100644 src/photo/ai/server.ts diff --git a/src/admin/AdminAddAllUploads.tsx b/src/admin/AdminAddAllUploads.tsx index 223e9e4f..cc8f452a 100644 --- a/src/admin/AdminAddAllUploads.tsx +++ b/src/admin/AdminAddAllUploads.tsx @@ -4,7 +4,7 @@ import ErrorNote from '@/components/ErrorNote'; import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import InfoBlock from '@/components/InfoBlock'; import LoaderButton from '@/components/primitives/LoaderButton'; -import { addAllUploads } from '@/photo/actions'; +import { addAllUploadsAction } from '@/photo/actions'; import { PATH_ADMIN_PHOTOS } from '@/site/paths'; import { TagsWithMeta, @@ -15,8 +15,7 @@ import { generateLocalNaivePostgresString, generateLocalPostgresString, } from '@/utility/date'; -import { convertStringToArray } from '@/utility/string'; -import clsx from 'clsx'; +import { clsx } from 'clsx/lite'; import { useRouter } from 'next/navigation'; import { useRef, useState } from 'react'; import { BiImageAdd } from 'react-icons/bi'; @@ -66,6 +65,7 @@ export default function AdminAddAllUploads({ , 100); } }} + readOnly={isLoading} />
} diff --git a/src/photo/actions.ts b/src/photo/actions.ts index 71ef0bbf..887da0f1 100644 --- a/src/photo/actions.ts +++ b/src/photo/actions.ts @@ -41,14 +41,19 @@ import { convertPhotoToPhotoDbInsert } from '.'; import { runAuthenticatedAdminServerAction } from '@/auth'; import { AI_IMAGE_QUERIES, AiImageQuery } from './ai'; import { streamOpenAiImageQuery } from '@/services/openai'; -import { AI_TEXT_GENERATION_ENABLED, BLUR_ENABLED } from '@/site/config'; +import { + AI_TEXT_AUTO_GENERATED_FIELDS, + AI_TEXT_GENERATION_ENABLED, + BLUR_ENABLED, +} from '@/site/config'; import { getStorageUploadUrlsNoStore } from '@/services/storage/cache'; +import { generateAiImageQueries } from './ai/server'; // Private actions export const createPhotoAction = async (formData: FormData) => runAuthenticatedAdminServerAction(async () => { - const photo = convertFormDataToPhotoDbInsert(formData, true); + const photo = convertFormDataToPhotoDbInsert(formData); const updatedUrl = await convertUploadToPhoto(photo.url); @@ -60,17 +65,18 @@ export const createPhotoAction = async (formData: FormData) => } }); -export const addAllUploads = async ({ +export const addAllUploadsAction = async ({ tags, takenAtLocal, takenAtNaiveLocal, }: { - tags: string[] + tags?: string takenAtLocal: string takenAtNaiveLocal: string }) => runAuthenticatedAdminServerAction(async () => { const uploadUrls = await getStorageUploadUrlsNoStore(); + for (const { url } of uploadUrls) { const { photoFormExif, @@ -82,23 +88,38 @@ export const addAllUploads = async ({ }); if (photoFormExif) { - const form = { + const { + title, + caption, + tags: aiTags, + semanticDescription, + } = await generateAiImageQueries( + imageResizedBase64, + AI_TEXT_AUTO_GENERATED_FIELDS, + ); + + const form: Partial = { ...photoFormExif, - tags, + title, + caption, + tags: tags || aiTags, + semanticDescription, takenAt: photoFormExif.takenAt || takenAtLocal, takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal, }; + + const updatedUrl = await convertUploadToPhoto(url); + if (updatedUrl) { + const photo = convertFormDataToPhotoDbInsert(form); + console.log(photo); + photo.url = updatedUrl; + await insertPhoto(photo); + } } - // const updatedUrl = await convertUploadToPhoto(url); - // if (updatedUrl) { - // const photo = convertFormDataToPhotoDbInsert(new FormData(), true); - // photo.url = updatedUrl; - // await insertPhoto(photo); - // } - // const photo = convertFormDataToPhotoDbInsert(new FormData(), true); - // photo.url = url; - // await insertPhoto(photo); } + + revalidateAllKeysAndPaths(); + redirect(PATH_ADMIN_PHOTOS); }); export const updatePhotoAction = async (formData: FormData) => diff --git a/src/photo/ai/index.ts b/src/photo/ai/index.ts index 4fbb228e..91db8318 100644 --- a/src/photo/ai/index.ts +++ b/src/photo/ai/index.ts @@ -63,3 +63,11 @@ export const parseTitleAndCaption = (text: string) => { caption: matches?.[2] ?? '', }; }; + +export const cleanUpAiTextResponse = (text?: string) => text + ? text + .replaceAll('\n', ' ') + .replaceAll('"', '') + .replace(/\.$/, '') + .trim() + : undefined; diff --git a/src/photo/ai/server.ts b/src/photo/ai/server.ts new file mode 100644 index 00000000..367c89c0 --- /dev/null +++ b/src/photo/ai/server.ts @@ -0,0 +1,77 @@ +import { generateOpenAiImageQuery } from '@/services/openai'; +import { + AI_IMAGE_QUERIES, + AiAutoGeneratedField, + cleanUpAiTextResponse, + parseTitleAndCaption, +} from '.'; + +export const generateAiImageQueries = async ( + imageBase64?: string, + textFieldsToGenerate: AiAutoGeneratedField[] = [], +): Promise<{ + title?: string + caption?: string + tags?: string + semanticDescription?: string +}> => { + let title: string | undefined; + let caption: string | undefined; + let tags: string | undefined; + let semanticDescription: string | undefined; + + if (imageBase64) { + if ( + textFieldsToGenerate.includes('title') && + textFieldsToGenerate.includes('caption') + ) { + const titleAndCaption = await generateOpenAiImageQuery( + imageBase64, + AI_IMAGE_QUERIES['title-and-caption'], + ); + if (titleAndCaption) { + const titleAndCaptionParsed = parseTitleAndCaption(titleAndCaption); + title = titleAndCaptionParsed.title; + caption = titleAndCaptionParsed.caption; + } + } else { + if (textFieldsToGenerate.includes('title')) { + title = cleanUpAiTextResponse( + await generateOpenAiImageQuery( + imageBase64, + AI_IMAGE_QUERIES['title'], + )); + } + if (textFieldsToGenerate.includes('caption')) { + caption = cleanUpAiTextResponse( + await generateOpenAiImageQuery( + imageBase64, + AI_IMAGE_QUERIES['caption'], + )); + } + } + + if (textFieldsToGenerate.includes('tags')) { + tags = cleanUpAiTextResponse( + await generateOpenAiImageQuery( + imageBase64, + AI_IMAGE_QUERIES['tags'], + )); + } + + if (textFieldsToGenerate.includes('semantic')) { + semanticDescription = cleanUpAiTextResponse( + await generateOpenAiImageQuery( + imageBase64, + AI_IMAGE_QUERIES['description-small'], + )); + } + } + + return { + title, + caption, + tags, + semanticDescription, + }; +}; diff --git a/src/photo/ai/useAiImageQueries.ts b/src/photo/ai/useAiImageQueries.ts index 8c78ab7c..5ed5af29 100644 --- a/src/photo/ai/useAiImageQueries.ts +++ b/src/photo/ai/useAiImageQueries.ts @@ -7,7 +7,7 @@ export type AiContent = ReturnType; export default function useAiImageQueries( textFieldsToAutoGenerate: AiAutoGeneratedField[] = [], - imageData?: string, + imageBase64?: string, ) { const [ requestTitleCaption, @@ -17,33 +17,33 @@ export default function useAiImageQueries( _isLoadingCaption, resetTitle, resetCaption, - ] = useTitleCaptionAiImageQuery(imageData); + ] = useTitleCaptionAiImageQuery(imageBase64); const [ requestTitle, titleSolo, isLoadingTitleSolo, resetTitleSolo, - ] = useAiImageQuery(imageData, 'title'); + ] = useAiImageQuery(imageBase64, 'title'); const [ requestCaption, captionSolo, isLoadingCaptionSolo, resetCaptionSolo, - ] = useAiImageQuery(imageData, 'caption'); + ] = useAiImageQuery(imageBase64, 'caption'); const [ requestTags, tags, isLoadingTags, - ] = useAiImageQuery(imageData, 'tags'); + ] = useAiImageQuery(imageBase64, 'tags'); const [ requestSemantic, semanticDescription, isLoadingSemantic, - ] = useAiImageQuery(imageData, 'description-small'); + ] = useAiImageQuery(imageBase64, 'description-small'); const title = _title || titleSolo; const caption = _caption || captionSolo; @@ -99,12 +99,12 @@ export default function useAiImageQueries( ]); useEffect(() => { - if (imageData && !hasRunAllQueriesOnce.current) { + if (imageBase64 && !hasRunAllQueriesOnce.current) { if (textFieldsToAutoGenerate.length > 0) { request(textFieldsToAutoGenerate); } } - }, [textFieldsToAutoGenerate, imageData, request]); + }, [textFieldsToAutoGenerate, imageBase64, request]); return { request, diff --git a/src/photo/ai/useAiImageQuery.ts b/src/photo/ai/useAiImageQuery.ts index 259d8c5c..f75c7395 100644 --- a/src/photo/ai/useAiImageQuery.ts +++ b/src/photo/ai/useAiImageQuery.ts @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import { streamAiImageQueryAction } from '../actions'; import { readStreamableValue } from 'ai/rsc'; -import { AiImageQuery } from '.'; +import { AiImageQuery, cleanUpAiTextResponse } from '.'; export default function useAiImageQuery( imageBase64: string | undefined, @@ -21,10 +21,9 @@ export default function useAiImageQuery( query, ); for await (const text of readStreamableValue(textStream)) { - setText(current => `${current}${text ?? ''}` - .replaceAll('\n', ' ') - .replaceAll('"', '') - .replace(/\.$/, '')); + setText(current => + cleanUpAiTextResponse(`${current}${text ?? ''}`) ?? '' + ); } setIsLoading(false); } catch (e) { diff --git a/src/photo/ai/useTitleCaptionAiImageQuery.ts b/src/photo/ai/useTitleCaptionAiImageQuery.ts index 6768decd..f99483ec 100644 --- a/src/photo/ai/useTitleCaptionAiImageQuery.ts +++ b/src/photo/ai/useTitleCaptionAiImageQuery.ts @@ -3,7 +3,7 @@ import useAiImageQuery from './useAiImageQuery'; import { parseTitleAndCaption } from '.'; export default function useTitleCaptionAiImageQuery( - imageBase64: string | undefined, + imageBase64?: string, ) { const [ request, diff --git a/src/photo/form/index.ts b/src/photo/form/index.ts index 3ad6bb27..da306ab0 100644 --- a/src/photo/form/index.ts +++ b/src/photo/form/index.ts @@ -1,5 +1,5 @@ import type { ExifData } from 'ts-exif-parser'; -import { Photo, PhotoDbInsert, PhotoExif } from '..'; +import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert, PhotoExif } from '..'; import { convertTimestampToNaivePostgresString, convertTimestampWithOffsetToPostgresString, @@ -20,7 +20,7 @@ import { TAG_FAVS, getValidationMessageForTags } from '@/tag'; type VirtualFields = 'favorite'; -export type PhotoFormData = Record; +export type PhotoFormData = Record export type FieldSetType = 'text' | @@ -217,8 +217,7 @@ export const convertExifToFormData = ( // PREPARE FORM FOR DB INSERT export const convertFormDataToPhotoDbInsert = ( - formData: FormData | PhotoFormData, - generateId?: boolean, + formData: FormData | Partial, ): PhotoDbInsert => { const photoForm = formData instanceof FormData ? Object.fromEntries(formData) as PhotoFormData @@ -245,11 +244,13 @@ export const convertFormDataToPhotoDbInsert = ( return { ...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }), - ...(generateId && !photoForm.id) && { id: generateNanoid() }, + ...!photoForm.id && { id: generateNanoid() }, // Convert form strings to arrays tags: tags.length > 0 ? tags : undefined, // Convert form strings to numbers - aspectRatio: roundToNumber(parseFloat(photoForm.aspectRatio), 6), + aspectRatio: photoForm.aspectRatio + ? roundToNumber(parseFloat(photoForm.aspectRatio), 6) + : DEFAULT_ASPECT_RATIO, focalLength: photoForm.focalLength ? parseInt(photoForm.focalLength) : undefined, diff --git a/src/photo/index.ts b/src/photo/index.ts index 01b0d0b0..0390ad58 100644 --- a/src/photo/index.ts +++ b/src/photo/index.ts @@ -31,6 +31,8 @@ export const INFINITE_SCROLL_GRID_PHOTO_MULTIPLE = HIGH_DENSITY_GRID // Thumbnails below /p/[photoId] export const RELATED_GRID_PHOTOS_TO_SHOW = 12; +export const DEFAULT_ASPECT_RATIO = 1.5; + export const ACCEPTED_PHOTO_FILE_TYPES = [ 'image/jpg', 'image/jpeg', diff --git a/src/services/openai.ts b/src/services/openai.ts index f8ff29f1..4eda3990 100644 --- a/src/services/openai.ts +++ b/src/services/openai.ts @@ -101,6 +101,6 @@ export const generateOpenAiImageQuery = async ( }, ], }], - }); + }).then(({ text }) => text); } };