EXIF title/caption capture (#294)
This commit is contained in:
parent
56a989afe6
commit
320f562cbc
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -20,6 +20,7 @@
|
|||||||
"depluralizes",
|
"depluralizes",
|
||||||
"Eterna",
|
"Eterna",
|
||||||
"exif",
|
"exif",
|
||||||
|
"exifr",
|
||||||
"exiftool",
|
"exiftool",
|
||||||
"favicons",
|
"favicons",
|
||||||
"Favoriting",
|
"Favoriting",
|
||||||
|
|||||||
19
package.json
19
package.json
@ -12,9 +12,9 @@
|
|||||||
"@ai-sdk/openai": "^1.3.23",
|
"@ai-sdk/openai": "^1.3.23",
|
||||||
"@aws-sdk/client-s3": "3.864.0",
|
"@aws-sdk/client-s3": "3.864.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.864.0",
|
"@aws-sdk/s3-request-presigner": "3.864.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.3",
|
"@radix-ui/react-visually-hidden": "^1.2.3",
|
||||||
"@upstash/ratelimit": "^2.0.6",
|
"@upstash/ratelimit": "^2.0.6",
|
||||||
"@upstash/redis": "^1.35.3",
|
"@upstash/redis": "^1.35.3",
|
||||||
@ -28,6 +28,7 @@
|
|||||||
"culori": "^4.0.2",
|
"culori": "^4.0.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"extract-colors": "^4.2.1",
|
"extract-colors": "^4.2.1",
|
||||||
"fast-average-color": "^9.5.0",
|
"fast-average-color": "^9.5.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@ -43,7 +44,7 @@
|
|||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swr": "^2.3.4",
|
"swr": "^2.3.6",
|
||||||
"ts-exif-parser": "^0.2.2",
|
"ts-exif-parser": "^0.2.2",
|
||||||
"use-debounce": "^10.0.5",
|
"use-debounce": "^10.0.5",
|
||||||
"viewerjs": "^1.11.7"
|
"viewerjs": "^1.11.7"
|
||||||
@ -55,15 +56,15 @@
|
|||||||
"@stylistic/eslint-plugin": "^5.2.3",
|
"@stylistic/eslint-plugin": "^5.2.3",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.6.4",
|
"@testing-library/jest-dom": "^6.7.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/culori": "^4.0.0",
|
"@types/culori": "^4.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.2.1",
|
"@types/node": "^24.3.0",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react": "19.1.9",
|
"@types/react": "19.1.10",
|
||||||
"@types/react-dom": "19.1.7",
|
"@types/react-dom": "19.1.7",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"cross-fetch": "^4.1.0",
|
"cross-fetch": "^4.1.0",
|
||||||
@ -73,7 +74,7 @@
|
|||||||
"jest": "^30.0.5",
|
"jest": "^30.0.5",
|
||||||
"jest-environment-jsdom": "^30.0.5",
|
"jest-environment-jsdom": "^30.0.5",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.12",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "5.9.2"
|
"typescript": "5.9.2"
|
||||||
},
|
},
|
||||||
|
|||||||
724
pnpm-lock.yaml
generated
724
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -37,8 +37,8 @@ export default function PhotoEditPageClient({
|
|||||||
setIsPending,
|
setIsPending,
|
||||||
updatedTitle,
|
updatedTitle,
|
||||||
setUpdatedTitle,
|
setUpdatedTitle,
|
||||||
hasTextContent,
|
shouldConfirmAiTextGeneration,
|
||||||
setHasTextContent,
|
setShouldConfirmAiTextGeneration,
|
||||||
aiContent,
|
aiContent,
|
||||||
} = usePhotoFormParent({
|
} = usePhotoFormParent({
|
||||||
photoForm,
|
photoForm,
|
||||||
@ -59,7 +59,10 @@ export default function PhotoEditPageClient({
|
|||||||
accessory={
|
accessory={
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{hasAiTextGeneration &&
|
{hasAiTextGeneration &&
|
||||||
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
|
<AiButton {...{
|
||||||
|
aiContent,
|
||||||
|
shouldConfirm: shouldConfirmAiTextGeneration,
|
||||||
|
}} />}
|
||||||
<ExifCaptureButton
|
<ExifCaptureButton
|
||||||
photoUrl={photo.url}
|
photoUrl={photo.url}
|
||||||
onSync={setUpdatedExifData}
|
onSync={setUpdatedExifData}
|
||||||
@ -77,7 +80,7 @@ export default function PhotoEditPageClient({
|
|||||||
uniqueFilms={uniqueFilms}
|
uniqueFilms={uniqueFilms}
|
||||||
aiContent={hasAiTextGeneration ? aiContent : undefined}
|
aiContent={hasAiTextGeneration ? aiContent : undefined}
|
||||||
onTitleChange={setUpdatedTitle}
|
onTitleChange={setUpdatedTitle}
|
||||||
onTextContentChange={setHasTextContent}
|
onFormDataChange={setShouldConfirmAiTextGeneration}
|
||||||
onFormStatusChange={setIsPending}
|
onFormStatusChange={setIsPending}
|
||||||
/>
|
/>
|
||||||
</AdminChildPage>
|
</AdminChildPage>
|
||||||
|
|||||||
@ -38,10 +38,11 @@ export default function UploadPageClient({
|
|||||||
setIsPending,
|
setIsPending,
|
||||||
updatedTitle,
|
updatedTitle,
|
||||||
setUpdatedTitle,
|
setUpdatedTitle,
|
||||||
hasTextContent,
|
shouldConfirmAiTextGeneration,
|
||||||
setHasTextContent,
|
setShouldConfirmAiTextGeneration,
|
||||||
aiContent,
|
aiContent,
|
||||||
} = usePhotoFormParent({
|
} = usePhotoFormParent({
|
||||||
|
photoForm: formDataFromExif,
|
||||||
textFieldsToAutoGenerate,
|
textFieldsToAutoGenerate,
|
||||||
imageThumbnailBase64,
|
imageThumbnailBase64,
|
||||||
});
|
});
|
||||||
@ -61,7 +62,10 @@ export default function UploadPageClient({
|
|||||||
: blobId}
|
: blobId}
|
||||||
breadcrumbEllipsis
|
breadcrumbEllipsis
|
||||||
accessory={hasAiTextGeneration &&
|
accessory={hasAiTextGeneration &&
|
||||||
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
|
<AiButton {...{
|
||||||
|
aiContent,
|
||||||
|
shouldConfirm: shouldConfirmAiTextGeneration,
|
||||||
|
}} />}
|
||||||
isLoading={pending}
|
isLoading={pending}
|
||||||
>
|
>
|
||||||
<PhotoForm
|
<PhotoForm
|
||||||
@ -72,8 +76,8 @@ export default function UploadPageClient({
|
|||||||
aiContent={hasAiTextGeneration ? aiContent : undefined}
|
aiContent={hasAiTextGeneration ? aiContent : undefined}
|
||||||
shouldStripGpsData={shouldStripGpsData}
|
shouldStripGpsData={shouldStripGpsData}
|
||||||
onTitleChange={setUpdatedTitle}
|
onTitleChange={setUpdatedTitle}
|
||||||
onTextContentChange={setHasTextContent}
|
|
||||||
onFormStatusChange={setIsPending}
|
onFormStatusChange={setIsPending}
|
||||||
|
onFormDataChange={setShouldConfirmAiTextGeneration}
|
||||||
/>
|
/>
|
||||||
</AdminChildPage>
|
</AdminChildPage>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -49,7 +49,7 @@ import {
|
|||||||
import { TAG_FAVS, isPhotoFav, isTagFavs } from '@/tag';
|
import { TAG_FAVS, isPhotoFav, isTagFavs } from '@/tag';
|
||||||
import { convertPhotoToPhotoDbInsert, Photo } from '.';
|
import { convertPhotoToPhotoDbInsert, Photo } from '.';
|
||||||
import { runAuthenticatedAdminServerAction } from '@/auth/server';
|
import { runAuthenticatedAdminServerAction } from '@/auth/server';
|
||||||
import { AiImageQuery, getAiImageQuery } from './ai';
|
import { AiImageQuery, getAiImageQuery, getAiTextFieldsToGenerate } from './ai';
|
||||||
import { streamOpenAiImageQuery } from '@/platforms/openai';
|
import { streamOpenAiImageQuery } from '@/platforms/openai';
|
||||||
import {
|
import {
|
||||||
AI_TEXT_AUTO_GENERATED_FIELDS,
|
AI_TEXT_AUTO_GENERATED_FIELDS,
|
||||||
@ -96,8 +96,8 @@ export const createPhotoAction = async (formData: FormData) =>
|
|||||||
// - addUploadsAction
|
// - addUploadsAction
|
||||||
const addUpload = async ({
|
const addUpload = async ({
|
||||||
url,
|
url,
|
||||||
title,
|
title: _title,
|
||||||
tags,
|
tags: _tags,
|
||||||
favorite,
|
favorite,
|
||||||
hidden,
|
hidden,
|
||||||
excludeFromFeeds,
|
excludeFromFeeds,
|
||||||
@ -138,24 +138,30 @@ const addUpload = async ({
|
|||||||
onStreamUpdate?.('Generating AI text');
|
onStreamUpdate?.('Generating AI text');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = _title || formDataFromExif.title;
|
||||||
|
const caption = formDataFromExif.caption;
|
||||||
|
const tags = _tags || formDataFromExif.tags;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
title: aiTitle,
|
title: aiTitle,
|
||||||
caption,
|
caption: aiCaption,
|
||||||
tags: aiTags,
|
tags: aiTags,
|
||||||
semanticDescription,
|
semanticDescription,
|
||||||
} = await generateAiImageQueries(
|
} = await generateAiImageQueries(
|
||||||
imageResizedBase64,
|
imageResizedBase64,
|
||||||
Boolean(title)
|
getAiTextFieldsToGenerate(
|
||||||
? AI_TEXT_AUTO_GENERATED_FIELDS
|
AI_TEXT_AUTO_GENERATED_FIELDS,
|
||||||
.filter(field => field !== 'title')
|
Boolean(title),
|
||||||
: AI_TEXT_AUTO_GENERATED_FIELDS,
|
Boolean(caption),
|
||||||
|
Boolean(tags),
|
||||||
|
),
|
||||||
title,
|
title,
|
||||||
);
|
);
|
||||||
|
|
||||||
const form: Partial<PhotoFormData> = {
|
const form: Partial<PhotoFormData> = {
|
||||||
...formDataFromExif,
|
...formDataFromExif,
|
||||||
title: title || aiTitle,
|
title: title || aiTitle,
|
||||||
caption,
|
caption: caption || aiCaption,
|
||||||
tags: tags || aiTags,
|
tags: tags || aiTags,
|
||||||
excludeFromFeeds,
|
excludeFromFeeds,
|
||||||
hidden,
|
hidden,
|
||||||
@ -190,9 +196,7 @@ const addUpload = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const addUploadAction = async (args: Parameters<typeof addUpload>[0]) =>
|
export const addUploadAction = async (args: Parameters<typeof addUpload>[0]) =>
|
||||||
runAuthenticatedAdminServerAction(async () => {
|
runAuthenticatedAdminServerAction(() => addUpload(args));
|
||||||
await addUpload(args);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const addUploadsAction = async ({
|
export const addUploadsAction = async ({
|
||||||
uploadUrls,
|
uploadUrls,
|
||||||
@ -536,7 +540,7 @@ export const syncPhotoAction = async (photoId: string, isBatch?: boolean) =>
|
|||||||
|
|
||||||
const formDataFromPhoto = convertPhotoToFormData(photo);
|
const formDataFromPhoto = convertPhotoToFormData(photo);
|
||||||
|
|
||||||
// Don't overwrite manually configured fujifilm meta with null data
|
// Don't overwrite manually configured meta with null data
|
||||||
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC.forEach(field => {
|
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC.forEach(field => {
|
||||||
if (!formDataFromExif[field] && formDataFromPhoto[field]) {
|
if (!formDataFromExif[field] && formDataFromPhoto[field]) {
|
||||||
delete formDataFromExif[field];
|
delete formDataFromExif[field];
|
||||||
|
|||||||
@ -40,6 +40,21 @@ export const parseAiAutoGeneratedFieldsString = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getAiTextFieldsToGenerate = (
|
||||||
|
textFieldsToGenerate: AiAutoGeneratedField[],
|
||||||
|
excludeTitle?: boolean,
|
||||||
|
excludeCaption?: boolean,
|
||||||
|
excludeTags?: boolean,
|
||||||
|
excludeSemantic?: boolean,
|
||||||
|
): AiAutoGeneratedField[] => {
|
||||||
|
return textFieldsToGenerate.filter(field =>
|
||||||
|
!(excludeTitle && field === 'title') &&
|
||||||
|
!(excludeCaption && field === 'caption') &&
|
||||||
|
!(excludeTags && field === 'tags') &&
|
||||||
|
!(excludeSemantic && field === 'semantic'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type AiImageQuery =
|
export type AiImageQuery =
|
||||||
'title' |
|
'title' |
|
||||||
'caption' |
|
'caption' |
|
||||||
|
|||||||
@ -61,7 +61,7 @@ const getColorDataFromImageUrl = async (
|
|||||||
url: string,
|
url: string,
|
||||||
isBatch?: boolean,
|
isBatch?: boolean,
|
||||||
): Promise<PhotoColorData> => {
|
): Promise<PhotoColorData> => {
|
||||||
const ai = AI_CONTENT_GENERATION_ENABLED
|
const ai = AI_CONTENT_GENERATION_ENABLED
|
||||||
? await getColorFromAI(url, isBatch)
|
? await getColorFromAI(url, isBatch)
|
||||||
: undefined;
|
: undefined;
|
||||||
const average = await getAverageColorFromImageUrl(url);
|
const average = await getAverageColorFromImageUrl(url);
|
||||||
@ -95,7 +95,7 @@ export const getColorFieldsForPhotoDbInsert = async (
|
|||||||
...args: Parameters<typeof getColorFieldsForImageUrl>
|
...args: Parameters<typeof getColorFieldsForImageUrl>
|
||||||
) => {
|
) => {
|
||||||
const { colorData, ...rest } = await getColorFieldsForImageUrl(...args) ?? {};
|
const { colorData, ...rest } = await getColorFieldsForImageUrl(...args) ?? {};
|
||||||
if (colorData) {
|
if (colorData !== undefined) {
|
||||||
return {
|
return {
|
||||||
colorData: JSON.stringify(colorData),
|
colorData: JSON.stringify(colorData),
|
||||||
...rest,
|
...rest,
|
||||||
@ -109,10 +109,12 @@ export const getColorFieldsForPhotoForm = async (
|
|||||||
) => {
|
) => {
|
||||||
const { colorSort, ...rest } =
|
const { colorSort, ...rest } =
|
||||||
await getColorFieldsForPhotoDbInsert(...args) ?? {};
|
await getColorFieldsForPhotoDbInsert(...args) ?? {};
|
||||||
return {
|
if (colorSort !== undefined) {
|
||||||
colorSort: `${colorSort}`,
|
return {
|
||||||
...rest,
|
colorSort: `${colorSort}`,
|
||||||
};
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getColorFromAI = async (
|
export const getColorFromAI = async (
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
FormMeta,
|
FormMeta,
|
||||||
PhotoFormData,
|
PhotoFormData,
|
||||||
convertFormKeysToLabels,
|
convertFormKeysToLabels,
|
||||||
formHasTextContent,
|
|
||||||
getChangedFormFields,
|
getChangedFormFields,
|
||||||
getFormErrors,
|
getFormErrors,
|
||||||
isFormValid,
|
isFormValid,
|
||||||
@ -65,7 +64,7 @@ export default function PhotoForm({
|
|||||||
aiContent,
|
aiContent,
|
||||||
shouldStripGpsData,
|
shouldStripGpsData,
|
||||||
onTitleChange,
|
onTitleChange,
|
||||||
onTextContentChange,
|
onFormDataChange,
|
||||||
onFormStatusChange,
|
onFormStatusChange,
|
||||||
}: {
|
}: {
|
||||||
type?: 'create' | 'edit'
|
type?: 'create' | 'edit'
|
||||||
@ -78,7 +77,7 @@ export default function PhotoForm({
|
|||||||
aiContent?: AiContent
|
aiContent?: AiContent
|
||||||
shouldStripGpsData?: boolean
|
shouldStripGpsData?: boolean
|
||||||
onTitleChange?: (updatedTitle: string) => void
|
onTitleChange?: (updatedTitle: string) => void
|
||||||
onTextContentChange?: (hasContent: boolean) => void,
|
onFormDataChange?: (formData: Partial<PhotoFormData>) => void,
|
||||||
onFormStatusChange?: (pending: boolean) => void
|
onFormStatusChange?: (pending: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const [formData, setFormData] =
|
const [formData, setFormData] =
|
||||||
@ -188,8 +187,8 @@ export default function PhotoForm({
|
|||||||
[aiContent?.semanticDescription]);
|
[aiContent?.semanticDescription]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onTextContentChange?.(formHasTextContent(formData));
|
onFormDataChange?.(formData);
|
||||||
}, [onTextContentChange, formData]);
|
}, [onFormDataChange, formData]);
|
||||||
|
|
||||||
const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
|
const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
|||||||
@ -85,6 +85,7 @@ const FORM_METADATA = (
|
|||||||
label: 'title',
|
label: 'title',
|
||||||
capitalize: true,
|
capitalize: true,
|
||||||
validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
|
validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
|
||||||
|
shouldNotOverwriteWithNullDataOnSync: true,
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
label: 'caption',
|
label: 'caption',
|
||||||
@ -245,12 +246,12 @@ export const isFormValid = (formData: Partial<PhotoFormData>) =>
|
|||||||
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength),
|
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const formHasTextContent = ({
|
export const formHasExistingAiTextContent = ({
|
||||||
title,
|
title,
|
||||||
caption,
|
caption,
|
||||||
tags,
|
tags,
|
||||||
semanticDescription,
|
semanticDescription,
|
||||||
}: Partial<PhotoFormData>) =>
|
}: Partial<PhotoFormData> = {}) =>
|
||||||
Boolean(title || caption || tags || semanticDescription);
|
Boolean(title || caption || tags || semanticDescription);
|
||||||
|
|
||||||
// CREATE FORM DATA: FROM PHOTO
|
// CREATE FORM DATA: FROM PHOTO
|
||||||
|
|||||||
@ -15,38 +15,55 @@ import type { ExifData } from 'ts-exif-parser';
|
|||||||
|
|
||||||
export const convertExifToFormData = (
|
export const convertExifToFormData = (
|
||||||
data: ExifData,
|
data: ExifData,
|
||||||
|
dataExifr?: any,
|
||||||
film?: FujifilmSimulation,
|
film?: FujifilmSimulation,
|
||||||
recipeData?: FujifilmRecipe,
|
recipeData?: FujifilmRecipe,
|
||||||
): Omit<
|
): Partial<Record<keyof PhotoExif, string | undefined>> => {
|
||||||
Record<keyof PhotoExif, string | undefined>,
|
let title: string | undefined = dataExifr?.title?.value;
|
||||||
'takenAt' | 'takenAtNaive'
|
let caption: string | undefined;
|
||||||
> => ({
|
const description: string | undefined =
|
||||||
aspectRatio: getAspectRatioFromExif(data).toString(),
|
data.tags?.ImageDescription ||
|
||||||
make: data.tags?.Make,
|
dataExifr?.ImageDescription ||
|
||||||
model: data.tags?.Model,
|
dataExifr?.description?.value;
|
||||||
focalLength: data.tags?.FocalLength?.toString(),
|
const tags: string[] | undefined = dataExifr?.subject;
|
||||||
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
|
|
||||||
lensMake: data.tags?.LensMake,
|
if (title && title !== description) {
|
||||||
lensModel: data.tags?.LensModel,
|
caption = description;
|
||||||
fNumber: (
|
} else {
|
||||||
data.tags?.FNumber?.toString() ||
|
title = description;
|
||||||
convertApertureValueToFNumber(data.tags?.ApertureValue)
|
}
|
||||||
),
|
|
||||||
iso: data.tags?.ISO?.toString() || data.tags?.ISOSpeed?.toString(),
|
return {
|
||||||
exposureTime: data.tags?.ExposureTime?.toString(),
|
aspectRatio: getAspectRatioFromExif(data).toString(),
|
||||||
exposureCompensation: data.tags?.ExposureCompensation?.toString(),
|
make: data.tags?.Make,
|
||||||
latitude:
|
model: data.tags?.Model,
|
||||||
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLatitude?.toString() : undefined,
|
focalLength: data.tags?.FocalLength?.toString(),
|
||||||
longitude:
|
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
|
||||||
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
|
lensMake: data.tags?.LensMake,
|
||||||
film,
|
lensModel: data.tags?.LensModel,
|
||||||
recipeData: JSON.stringify(recipeData),
|
fNumber: (
|
||||||
...data.tags?.DateTimeOriginal && {
|
data.tags?.FNumber?.toString() ||
|
||||||
takenAt: convertTimestampWithOffsetToPostgresString(
|
convertApertureValueToFNumber(data.tags?.ApertureValue)
|
||||||
data.tags.DateTimeOriginal,
|
|
||||||
getOffsetFromExif(data),
|
|
||||||
),
|
),
|
||||||
takenAtNaive:
|
iso: data.tags?.ISO?.toString() || data.tags?.ISOSpeed?.toString(),
|
||||||
convertTimestampToNaivePostgresString(data.tags.DateTimeOriginal),
|
exposureTime: data.tags?.ExposureTime?.toString(),
|
||||||
},
|
exposureCompensation: data.tags?.ExposureCompensation?.toString(),
|
||||||
});
|
latitude:
|
||||||
|
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLatitude?.toString() : undefined,
|
||||||
|
longitude:
|
||||||
|
!GEO_PRIVACY_ENABLED ? data.tags?.GPSLongitude?.toString() : undefined,
|
||||||
|
film,
|
||||||
|
recipeData: JSON.stringify(recipeData),
|
||||||
|
...data.tags?.DateTimeOriginal && {
|
||||||
|
takenAt: convertTimestampWithOffsetToPostgresString(
|
||||||
|
data.tags.DateTimeOriginal,
|
||||||
|
getOffsetFromExif(data),
|
||||||
|
),
|
||||||
|
takenAtNaive:
|
||||||
|
convertTimestampToNaivePostgresString(data.tags.DateTimeOriginal),
|
||||||
|
},
|
||||||
|
...title && { title },
|
||||||
|
...caption && { caption },
|
||||||
|
...Array.isArray(tags) && { tags: tags.join(', ') },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { PhotoFormData, formHasTextContent } from '.';
|
import { PhotoFormData, formHasExistingAiTextContent } from '.';
|
||||||
import useAiImageQueries from '../ai/useAiImageQueries';
|
import useAiImageQueries from '../ai/useAiImageQueries';
|
||||||
import { AiAutoGeneratedField } from '../ai';
|
import { AiAutoGeneratedField, getAiTextFieldsToGenerate } from '../ai';
|
||||||
|
|
||||||
export default function usePhotoFormParent({
|
export default function usePhotoFormParent({
|
||||||
photoForm,
|
photoForm,
|
||||||
textFieldsToAutoGenerate,
|
textFieldsToAutoGenerate: _textFieldsToAutoGenerate = [],
|
||||||
imageThumbnailBase64,
|
imageThumbnailBase64,
|
||||||
}: {
|
}: {
|
||||||
photoForm?: Partial<PhotoFormData>
|
photoForm?: Partial<PhotoFormData>
|
||||||
@ -14,8 +14,29 @@ export default function usePhotoFormParent({
|
|||||||
}) {
|
}) {
|
||||||
const [pending, setIsPending] = useState(false);
|
const [pending, setIsPending] = useState(false);
|
||||||
const [updatedTitle, setUpdatedTitle] = useState('');
|
const [updatedTitle, setUpdatedTitle] = useState('');
|
||||||
const [hasTextContent, setHasTextContent] =
|
const [shouldConfirmAiTextGeneration, _setShouldConfirmAiTextGeneration] =
|
||||||
useState(photoForm ? formHasTextContent(photoForm) : false);
|
useState(formHasExistingAiTextContent(photoForm));
|
||||||
|
|
||||||
|
const setShouldConfirmAiTextGeneration = useCallback(
|
||||||
|
(updatedFormData: Partial<PhotoFormData>) => {
|
||||||
|
_setShouldConfirmAiTextGeneration(
|
||||||
|
formHasExistingAiTextContent(updatedFormData),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Don't auto-generate titles when they can be captured from EXIF data
|
||||||
|
const textFieldsToAutoGenerate = useMemo(() =>
|
||||||
|
getAiTextFieldsToGenerate(
|
||||||
|
_textFieldsToAutoGenerate,
|
||||||
|
Boolean(photoForm?.title),
|
||||||
|
Boolean(photoForm?.caption),
|
||||||
|
Boolean(photoForm?.tags),
|
||||||
|
), [
|
||||||
|
_textFieldsToAutoGenerate,
|
||||||
|
photoForm?.title,
|
||||||
|
photoForm?.caption,
|
||||||
|
photoForm?.tags,
|
||||||
|
]);
|
||||||
|
|
||||||
const aiContent = useAiImageQueries(
|
const aiContent = useAiImageQueries(
|
||||||
textFieldsToAutoGenerate,
|
textFieldsToAutoGenerate,
|
||||||
@ -27,8 +48,8 @@ export default function usePhotoFormParent({
|
|||||||
setIsPending,
|
setIsPending,
|
||||||
updatedTitle,
|
updatedTitle,
|
||||||
setUpdatedTitle,
|
setUpdatedTitle,
|
||||||
hasTextContent,
|
shouldConfirmAiTextGeneration,
|
||||||
setHasTextContent,
|
setShouldConfirmAiTextGeneration,
|
||||||
aiContent,
|
aiContent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,6 +68,10 @@ export interface PhotoExif {
|
|||||||
recipeData?: string
|
recipeData?: string
|
||||||
takenAt?: string
|
takenAt?: string
|
||||||
takenAtNaive?: string
|
takenAtNaive?: string
|
||||||
|
// Photo meta potentially located in EXIF/XMP data
|
||||||
|
title?: string
|
||||||
|
caption?: string
|
||||||
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw db insert
|
// Raw db insert
|
||||||
@ -76,7 +80,6 @@ export interface PhotoDbInsert extends PhotoExif {
|
|||||||
url: string
|
url: string
|
||||||
extension: string
|
extension: string
|
||||||
blurData?: string
|
blurData?: string
|
||||||
title?: string
|
|
||||||
caption?: string
|
caption?: string
|
||||||
semanticDescription?: string
|
semanticDescription?: string
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import {
|
|||||||
import { PhotoDbInsert } from '.';
|
import { PhotoDbInsert } from '.';
|
||||||
import { convertExifToFormData } from './form/server';
|
import { convertExifToFormData } from './form/server';
|
||||||
import { getColorFieldsForPhotoForm } from './color/server';
|
import { getColorFieldsForPhotoForm } from './color/server';
|
||||||
|
import exifr from 'exifr';
|
||||||
|
|
||||||
const IMAGE_WIDTH_RESIZE = 200;
|
const IMAGE_WIDTH_RESIZE = 200;
|
||||||
const IMAGE_WIDTH_BLUR = 200;
|
const IMAGE_WIDTH_BLUR = 200;
|
||||||
@ -58,6 +59,7 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
const extension = getExtensionFromStorageUrl(url);
|
const extension = getExtensionFromStorageUrl(url);
|
||||||
|
|
||||||
let exifData: ExifData | undefined;
|
let exifData: ExifData | undefined;
|
||||||
|
let exifrData: any | undefined;
|
||||||
let film: FujifilmSimulation | undefined;
|
let film: FujifilmSimulation | undefined;
|
||||||
let recipe: FujifilmRecipe | undefined;
|
let recipe: FujifilmRecipe | undefined;
|
||||||
let blurData: string | undefined;
|
let blurData: string | undefined;
|
||||||
@ -80,6 +82,7 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
// Data for form
|
// Data for form
|
||||||
parser.enableBinaryFields(false);
|
parser.enableBinaryFields(false);
|
||||||
exifData = parser.parse();
|
exifData = parser.parse();
|
||||||
|
exifrData = await exifr.parse(fileBytes, { xmp: true });
|
||||||
|
|
||||||
// Capture film simulation for Fujifilm cameras
|
// Capture film simulation for Fujifilm cameras
|
||||||
if (isExifForFujifilm(exifData)) {
|
if (isExifForFujifilm(exifData)) {
|
||||||
@ -126,7 +129,7 @@ export const extractImageDataFromBlobPath = async (
|
|||||||
url,
|
url,
|
||||||
},
|
},
|
||||||
...generateBlurData && { blurData },
|
...generateBlurData && { blurData },
|
||||||
...convertExifToFormData(exifData, film, recipe),
|
...convertExifToFormData(exifData, exifrData, film, recipe),
|
||||||
...colorFields,
|
...colorFields,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user