Finalize multi-image upload backend data processing

This commit is contained in:
Sam Becker 2024-05-27 00:16:09 -05:00
parent 3039076e27
commit 31396b83cc
11 changed files with 156 additions and 43 deletions

View File

@ -4,7 +4,7 @@ import ErrorNote from '@/components/ErrorNote';
import FieldSetWithStatus from '@/components/FieldSetWithStatus'; import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import InfoBlock from '@/components/InfoBlock'; import InfoBlock from '@/components/InfoBlock';
import LoaderButton from '@/components/primitives/LoaderButton'; import LoaderButton from '@/components/primitives/LoaderButton';
import { addAllUploads } from '@/photo/actions'; import { addAllUploadsAction } from '@/photo/actions';
import { PATH_ADMIN_PHOTOS } from '@/site/paths'; import { PATH_ADMIN_PHOTOS } from '@/site/paths';
import { import {
TagsWithMeta, TagsWithMeta,
@ -15,8 +15,7 @@ import {
generateLocalNaivePostgresString, generateLocalNaivePostgresString,
generateLocalPostgresString, generateLocalPostgresString,
} from '@/utility/date'; } from '@/utility/date';
import { convertStringToArray } from '@/utility/string'; import { clsx } from 'clsx/lite';
import clsx from 'clsx';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { BiImageAdd } from 'react-icons/bi'; import { BiImageAdd } from 'react-icons/bi';
@ -66,6 +65,7 @@ export default function AdminAddAllUploads({
, 100); , 100);
} }
}} }}
readOnly={isLoading}
/> />
</div> </div>
<div <div
@ -81,6 +81,7 @@ export default function AdminAddAllUploads({
setTags(tags); setTags(tags);
setTagErrorMessage(getValidationMessageForTags(tags) ?? ''); setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
}} }}
readOnly={isLoading}
error={tagErrorMessage} error={tagErrorMessage}
required={false} required={false}
hideLabel hideLabel
@ -97,10 +98,8 @@ export default function AdminAddAllUploads({
`Are you sure you want to add all ${storageUrlCount} uploads?` `Are you sure you want to add all ${storageUrlCount} uploads?`
)) { )) {
setIsLoading(true); setIsLoading(true);
addAllUploads({ addAllUploadsAction({
tags: showTags && tags tags: showTags ? tags : undefined,
? convertStringToArray(tags) ?? []
: [],
takenAtLocal: generateLocalPostgresString(), takenAtLocal: generateLocalPostgresString(),
takenAtNaiveLocal: generateLocalNaivePostgresString(), takenAtNaiveLocal: generateLocalNaivePostgresString(),
}) })

View File

@ -149,12 +149,18 @@ export default function FieldSetWithStatus({
autoComplete="off" autoComplete="off"
autoCapitalize={!capitalize ? 'off' : undefined} autoCapitalize={!capitalize ? 'off' : undefined}
readOnly={readOnly || pending || loading} readOnly={readOnly || pending || loading}
disabled={type === 'checkbox' && (
readOnly || pending || loading
)}
className={clsx( className={clsx(
( (
type === 'text' || type === 'text' ||
type === 'email' || type === 'email' ||
type === 'password' type === 'password'
) && 'w-full', ) && 'w-full',
type === 'checkbox' && (
readOnly || pending || loading
) && 'opacity-50 cursor-not-allowed',
Boolean(error) && 'error', Boolean(error) && 'error',
)} )}
/>} />}

View File

@ -41,14 +41,19 @@ import { convertPhotoToPhotoDbInsert } from '.';
import { runAuthenticatedAdminServerAction } from '@/auth'; import { runAuthenticatedAdminServerAction } from '@/auth';
import { AI_IMAGE_QUERIES, AiImageQuery } from './ai'; import { AI_IMAGE_QUERIES, AiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/services/openai'; 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 { getStorageUploadUrlsNoStore } from '@/services/storage/cache';
import { generateAiImageQueries } from './ai/server';
// Private actions // Private actions
export const createPhotoAction = async (formData: FormData) => export const createPhotoAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const photo = convertFormDataToPhotoDbInsert(formData, true); const photo = convertFormDataToPhotoDbInsert(formData);
const updatedUrl = await convertUploadToPhoto(photo.url); 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, tags,
takenAtLocal, takenAtLocal,
takenAtNaiveLocal, takenAtNaiveLocal,
}: { }: {
tags: string[] tags?: string
takenAtLocal: string takenAtLocal: string
takenAtNaiveLocal: string takenAtNaiveLocal: string
}) => }) =>
runAuthenticatedAdminServerAction(async () => { runAuthenticatedAdminServerAction(async () => {
const uploadUrls = await getStorageUploadUrlsNoStore(); const uploadUrls = await getStorageUploadUrlsNoStore();
for (const { url } of uploadUrls) { for (const { url } of uploadUrls) {
const { const {
photoFormExif, photoFormExif,
@ -82,23 +88,38 @@ export const addAllUploads = async ({
}); });
if (photoFormExif) { if (photoFormExif) {
const form = { const {
title,
caption,
tags: aiTags,
semanticDescription,
} = await generateAiImageQueries(
imageResizedBase64,
AI_TEXT_AUTO_GENERATED_FIELDS,
);
const form: Partial<PhotoFormData> = {
...photoFormExif, ...photoFormExif,
tags, title,
caption,
tags: tags || aiTags,
semanticDescription,
takenAt: photoFormExif.takenAt || takenAtLocal, takenAt: photoFormExif.takenAt || takenAtLocal,
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal, 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) => export const updatePhotoAction = async (formData: FormData) =>

View File

@ -63,3 +63,11 @@ export const parseTitleAndCaption = (text: string) => {
caption: matches?.[2] ?? '', caption: matches?.[2] ?? '',
}; };
}; };
export const cleanUpAiTextResponse = (text?: string) => text
? text
.replaceAll('\n', ' ')
.replaceAll('"', '')
.replace(/\.$/, '')
.trim()
: undefined;

77
src/photo/ai/server.ts Normal file
View File

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

View File

@ -7,7 +7,7 @@ export type AiContent = ReturnType<typeof useAiImageQueries>;
export default function useAiImageQueries( export default function useAiImageQueries(
textFieldsToAutoGenerate: AiAutoGeneratedField[] = [], textFieldsToAutoGenerate: AiAutoGeneratedField[] = [],
imageData?: string, imageBase64?: string,
) { ) {
const [ const [
requestTitleCaption, requestTitleCaption,
@ -17,33 +17,33 @@ export default function useAiImageQueries(
_isLoadingCaption, _isLoadingCaption,
resetTitle, resetTitle,
resetCaption, resetCaption,
] = useTitleCaptionAiImageQuery(imageData); ] = useTitleCaptionAiImageQuery(imageBase64);
const [ const [
requestTitle, requestTitle,
titleSolo, titleSolo,
isLoadingTitleSolo, isLoadingTitleSolo,
resetTitleSolo, resetTitleSolo,
] = useAiImageQuery(imageData, 'title'); ] = useAiImageQuery(imageBase64, 'title');
const [ const [
requestCaption, requestCaption,
captionSolo, captionSolo,
isLoadingCaptionSolo, isLoadingCaptionSolo,
resetCaptionSolo, resetCaptionSolo,
] = useAiImageQuery(imageData, 'caption'); ] = useAiImageQuery(imageBase64, 'caption');
const [ const [
requestTags, requestTags,
tags, tags,
isLoadingTags, isLoadingTags,
] = useAiImageQuery(imageData, 'tags'); ] = useAiImageQuery(imageBase64, 'tags');
const [ const [
requestSemantic, requestSemantic,
semanticDescription, semanticDescription,
isLoadingSemantic, isLoadingSemantic,
] = useAiImageQuery(imageData, 'description-small'); ] = useAiImageQuery(imageBase64, 'description-small');
const title = _title || titleSolo; const title = _title || titleSolo;
const caption = _caption || captionSolo; const caption = _caption || captionSolo;
@ -99,12 +99,12 @@ export default function useAiImageQueries(
]); ]);
useEffect(() => { useEffect(() => {
if (imageData && !hasRunAllQueriesOnce.current) { if (imageBase64 && !hasRunAllQueriesOnce.current) {
if (textFieldsToAutoGenerate.length > 0) { if (textFieldsToAutoGenerate.length > 0) {
request(textFieldsToAutoGenerate); request(textFieldsToAutoGenerate);
} }
} }
}, [textFieldsToAutoGenerate, imageData, request]); }, [textFieldsToAutoGenerate, imageBase64, request]);
return { return {
request, request,

View File

@ -1,7 +1,7 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { streamAiImageQueryAction } from '../actions'; import { streamAiImageQueryAction } from '../actions';
import { readStreamableValue } from 'ai/rsc'; import { readStreamableValue } from 'ai/rsc';
import { AiImageQuery } from '.'; import { AiImageQuery, cleanUpAiTextResponse } from '.';
export default function useAiImageQuery( export default function useAiImageQuery(
imageBase64: string | undefined, imageBase64: string | undefined,
@ -21,10 +21,9 @@ export default function useAiImageQuery(
query, query,
); );
for await (const text of readStreamableValue(textStream)) { for await (const text of readStreamableValue(textStream)) {
setText(current => `${current}${text ?? ''}` setText(current =>
.replaceAll('\n', ' ') cleanUpAiTextResponse(`${current}${text ?? ''}`) ?? ''
.replaceAll('"', '') );
.replace(/\.$/, ''));
} }
setIsLoading(false); setIsLoading(false);
} catch (e) { } catch (e) {

View File

@ -3,7 +3,7 @@ import useAiImageQuery from './useAiImageQuery';
import { parseTitleAndCaption } from '.'; import { parseTitleAndCaption } from '.';
export default function useTitleCaptionAiImageQuery( export default function useTitleCaptionAiImageQuery(
imageBase64: string | undefined, imageBase64?: string,
) { ) {
const [ const [
request, request,

View File

@ -1,5 +1,5 @@
import type { ExifData } from 'ts-exif-parser'; import type { ExifData } from 'ts-exif-parser';
import { Photo, PhotoDbInsert, PhotoExif } from '..'; import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert, PhotoExif } from '..';
import { import {
convertTimestampToNaivePostgresString, convertTimestampToNaivePostgresString,
convertTimestampWithOffsetToPostgresString, convertTimestampWithOffsetToPostgresString,
@ -20,7 +20,7 @@ import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
type VirtualFields = 'favorite'; type VirtualFields = 'favorite';
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>; export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>
export type FieldSetType = export type FieldSetType =
'text' | 'text' |
@ -217,8 +217,7 @@ export const convertExifToFormData = (
// PREPARE FORM FOR DB INSERT // PREPARE FORM FOR DB INSERT
export const convertFormDataToPhotoDbInsert = ( export const convertFormDataToPhotoDbInsert = (
formData: FormData | PhotoFormData, formData: FormData | Partial<PhotoFormData>,
generateId?: boolean,
): PhotoDbInsert => { ): PhotoDbInsert => {
const photoForm = formData instanceof FormData const photoForm = formData instanceof FormData
? Object.fromEntries(formData) as PhotoFormData ? Object.fromEntries(formData) as PhotoFormData
@ -245,11 +244,13 @@ export const convertFormDataToPhotoDbInsert = (
return { return {
...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }), ...(photoForm as PhotoFormData & { filmSimulation?: FilmSimulation }),
...(generateId && !photoForm.id) && { id: generateNanoid() }, ...!photoForm.id && { id: generateNanoid() },
// Convert form strings to arrays // Convert form strings to arrays
tags: tags.length > 0 ? tags : undefined, tags: tags.length > 0 ? tags : undefined,
// Convert form strings to numbers // 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 focalLength: photoForm.focalLength
? parseInt(photoForm.focalLength) ? parseInt(photoForm.focalLength)
: undefined, : undefined,

View File

@ -31,6 +31,8 @@ export const INFINITE_SCROLL_GRID_PHOTO_MULTIPLE = HIGH_DENSITY_GRID
// Thumbnails below /p/[photoId] // Thumbnails below /p/[photoId]
export const RELATED_GRID_PHOTOS_TO_SHOW = 12; export const RELATED_GRID_PHOTOS_TO_SHOW = 12;
export const DEFAULT_ASPECT_RATIO = 1.5;
export const ACCEPTED_PHOTO_FILE_TYPES = [ export const ACCEPTED_PHOTO_FILE_TYPES = [
'image/jpg', 'image/jpg',
'image/jpeg', 'image/jpeg',

View File

@ -101,6 +101,6 @@ export const generateOpenAiImageQuery = async (
}, },
], ],
}], }],
}); }).then(({ text }) => text);
} }
}; };