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 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}
/>
</div>
<div
@ -81,6 +81,7 @@ export default function AdminAddAllUploads({
setTags(tags);
setTagErrorMessage(getValidationMessageForTags(tags) ?? '');
}}
readOnly={isLoading}
error={tagErrorMessage}
required={false}
hideLabel
@ -97,10 +98,8 @@ export default function AdminAddAllUploads({
`Are you sure you want to add all ${storageUrlCount} uploads?`
)) {
setIsLoading(true);
addAllUploads({
tags: showTags && tags
? convertStringToArray(tags) ?? []
: [],
addAllUploadsAction({
tags: showTags ? tags : undefined,
takenAtLocal: generateLocalPostgresString(),
takenAtNaiveLocal: generateLocalNaivePostgresString(),
})

View File

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

View File

@ -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<PhotoFormData> = {
...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) =>

View File

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

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(
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,

View File

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

View File

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

View File

@ -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<keyof PhotoDbInsert | VirtualFields, string>;
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>
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<PhotoFormData>,
): 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,

View File

@ -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',

View File

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