EXIF title/caption capture (#294)

This commit is contained in:
Sam Becker 2025-08-18 00:18:16 +02:00 committed by GitHub
parent 56a989afe6
commit 320f562cbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 526 additions and 442 deletions

View File

@ -20,6 +20,7 @@
"depluralizes",
"Eterna",
"exif",
"exifr",
"exiftool",
"favicons",
"Favoriting",

View File

@ -12,9 +12,9 @@
"@ai-sdk/openai": "^1.3.23",
"@aws-sdk/client-s3": "3.864.0",
"@aws-sdk/s3-request-presigner": "3.864.0",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.3",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.3",
@ -28,6 +28,7 @@
"culori": "^4.0.2",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"exifr": "^7.1.3",
"extract-colors": "^4.2.1",
"fast-average-color": "^9.5.0",
"fast-deep-equal": "^3.1.3",
@ -43,7 +44,7 @@
"sanitize-html": "^2.17.0",
"sharp": "^0.34.3",
"sonner": "^2.0.7",
"swr": "^2.3.4",
"swr": "^2.3.6",
"ts-exif-parser": "^0.2.2",
"use-debounce": "^10.0.5",
"viewerjs": "^1.11.7"
@ -55,15 +56,15 @@
"@stylistic/eslint-plugin": "^5.2.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/postcss": "^4.1.12",
"@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",
"@types/culori": "^4.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.2.1",
"@types/node": "^24.3.0",
"@types/pg": "^8.15.5",
"@types/react": "19.1.9",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@types/sanitize-html": "^2.16.0",
"cross-fetch": "^4.1.0",
@ -73,7 +74,7 @@
"jest": "^30.0.5",
"jest-environment-jsdom": "^30.0.5",
"postcss": "8.5.6",
"tailwindcss": "4.1.11",
"tailwindcss": "4.1.12",
"ts-node": "^10.9.2",
"typescript": "5.9.2"
},

724
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -37,8 +37,8 @@ export default function PhotoEditPageClient({
setIsPending,
updatedTitle,
setUpdatedTitle,
hasTextContent,
setHasTextContent,
shouldConfirmAiTextGeneration,
setShouldConfirmAiTextGeneration,
aiContent,
} = usePhotoFormParent({
photoForm,
@ -59,7 +59,10 @@ export default function PhotoEditPageClient({
accessory={
<div className="flex gap-2">
{hasAiTextGeneration &&
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
<AiButton {...{
aiContent,
shouldConfirm: shouldConfirmAiTextGeneration,
}} />}
<ExifCaptureButton
photoUrl={photo.url}
onSync={setUpdatedExifData}
@ -77,7 +80,7 @@ export default function PhotoEditPageClient({
uniqueFilms={uniqueFilms}
aiContent={hasAiTextGeneration ? aiContent : undefined}
onTitleChange={setUpdatedTitle}
onTextContentChange={setHasTextContent}
onFormDataChange={setShouldConfirmAiTextGeneration}
onFormStatusChange={setIsPending}
/>
</AdminChildPage>

View File

@ -38,10 +38,11 @@ export default function UploadPageClient({
setIsPending,
updatedTitle,
setUpdatedTitle,
hasTextContent,
setHasTextContent,
shouldConfirmAiTextGeneration,
setShouldConfirmAiTextGeneration,
aiContent,
} = usePhotoFormParent({
photoForm: formDataFromExif,
textFieldsToAutoGenerate,
imageThumbnailBase64,
});
@ -61,7 +62,10 @@ export default function UploadPageClient({
: blobId}
breadcrumbEllipsis
accessory={hasAiTextGeneration &&
<AiButton {...{ aiContent, shouldConfirm: hasTextContent }} />}
<AiButton {...{
aiContent,
shouldConfirm: shouldConfirmAiTextGeneration,
}} />}
isLoading={pending}
>
<PhotoForm
@ -72,8 +76,8 @@ export default function UploadPageClient({
aiContent={hasAiTextGeneration ? aiContent : undefined}
shouldStripGpsData={shouldStripGpsData}
onTitleChange={setUpdatedTitle}
onTextContentChange={setHasTextContent}
onFormStatusChange={setIsPending}
onFormDataChange={setShouldConfirmAiTextGeneration}
/>
</AdminChildPage>
);

View File

@ -49,7 +49,7 @@ import {
import { TAG_FAVS, isPhotoFav, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert, Photo } from '.';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { AiImageQuery, getAiImageQuery } from './ai';
import { AiImageQuery, getAiImageQuery, getAiTextFieldsToGenerate } from './ai';
import { streamOpenAiImageQuery } from '@/platforms/openai';
import {
AI_TEXT_AUTO_GENERATED_FIELDS,
@ -96,8 +96,8 @@ export const createPhotoAction = async (formData: FormData) =>
// - addUploadsAction
const addUpload = async ({
url,
title,
tags,
title: _title,
tags: _tags,
favorite,
hidden,
excludeFromFeeds,
@ -138,24 +138,30 @@ const addUpload = async ({
onStreamUpdate?.('Generating AI text');
}
const title = _title || formDataFromExif.title;
const caption = formDataFromExif.caption;
const tags = _tags || formDataFromExif.tags;
const {
title: aiTitle,
caption,
caption: aiCaption,
tags: aiTags,
semanticDescription,
} = await generateAiImageQueries(
imageResizedBase64,
Boolean(title)
? AI_TEXT_AUTO_GENERATED_FIELDS
.filter(field => field !== 'title')
: AI_TEXT_AUTO_GENERATED_FIELDS,
getAiTextFieldsToGenerate(
AI_TEXT_AUTO_GENERATED_FIELDS,
Boolean(title),
Boolean(caption),
Boolean(tags),
),
title,
);
const form: Partial<PhotoFormData> = {
...formDataFromExif,
title: title || aiTitle,
caption,
caption: caption || aiCaption,
tags: tags || aiTags,
excludeFromFeeds,
hidden,
@ -190,9 +196,7 @@ const addUpload = async ({
};
export const addUploadAction = async (args: Parameters<typeof addUpload>[0]) =>
runAuthenticatedAdminServerAction(async () => {
await addUpload(args);
});
runAuthenticatedAdminServerAction(() => addUpload(args));
export const addUploadsAction = async ({
uploadUrls,
@ -536,7 +540,7 @@ export const syncPhotoAction = async (photoId: string, isBatch?: boolean) =>
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 => {
if (!formDataFromExif[field] && formDataFromPhoto[field]) {
delete formDataFromExif[field];

View File

@ -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 =
'title' |
'caption' |

View File

@ -61,7 +61,7 @@ const getColorDataFromImageUrl = async (
url: string,
isBatch?: boolean,
): Promise<PhotoColorData> => {
const ai = AI_CONTENT_GENERATION_ENABLED
const ai = AI_CONTENT_GENERATION_ENABLED
? await getColorFromAI(url, isBatch)
: undefined;
const average = await getAverageColorFromImageUrl(url);
@ -95,7 +95,7 @@ export const getColorFieldsForPhotoDbInsert = async (
...args: Parameters<typeof getColorFieldsForImageUrl>
) => {
const { colorData, ...rest } = await getColorFieldsForImageUrl(...args) ?? {};
if (colorData) {
if (colorData !== undefined) {
return {
colorData: JSON.stringify(colorData),
...rest,
@ -109,10 +109,12 @@ export const getColorFieldsForPhotoForm = async (
) => {
const { colorSort, ...rest } =
await getColorFieldsForPhotoDbInsert(...args) ?? {};
return {
colorSort: `${colorSort}`,
...rest,
};
if (colorSort !== undefined) {
return {
colorSort: `${colorSort}`,
...rest,
};
}
};
export const getColorFromAI = async (

View File

@ -14,7 +14,6 @@ import {
FormMeta,
PhotoFormData,
convertFormKeysToLabels,
formHasTextContent,
getChangedFormFields,
getFormErrors,
isFormValid,
@ -65,7 +64,7 @@ export default function PhotoForm({
aiContent,
shouldStripGpsData,
onTitleChange,
onTextContentChange,
onFormDataChange,
onFormStatusChange,
}: {
type?: 'create' | 'edit'
@ -78,7 +77,7 @@ export default function PhotoForm({
aiContent?: AiContent
shouldStripGpsData?: boolean
onTitleChange?: (updatedTitle: string) => void
onTextContentChange?: (hasContent: boolean) => void,
onFormDataChange?: (formData: Partial<PhotoFormData>) => void,
onFormStatusChange?: (pending: boolean) => void
}) {
const [formData, setFormData] =
@ -188,8 +187,8 @@ export default function PhotoForm({
[aiContent?.semanticDescription]);
useEffect(() => {
onTextContentChange?.(formHasTextContent(formData));
}, [onTextContentChange, formData]);
onFormDataChange?.(formData);
}, [onFormDataChange, formData]);
const isFieldGeneratingAi = (key: keyof PhotoFormData) => {
switch (key) {

View File

@ -85,6 +85,7 @@ const FORM_METADATA = (
label: 'title',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
shouldNotOverwriteWithNullDataOnSync: true,
},
caption: {
label: 'caption',
@ -245,12 +246,12 @@ export const isFormValid = (formData: Partial<PhotoFormData>) =>
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength),
);
export const formHasTextContent = ({
export const formHasExistingAiTextContent = ({
title,
caption,
tags,
semanticDescription,
}: Partial<PhotoFormData>) =>
}: Partial<PhotoFormData> = {}) =>
Boolean(title || caption || tags || semanticDescription);
// CREATE FORM DATA: FROM PHOTO

View File

@ -15,38 +15,55 @@ import type { ExifData } from 'ts-exif-parser';
export const convertExifToFormData = (
data: ExifData,
dataExifr?: any,
film?: FujifilmSimulation,
recipeData?: FujifilmRecipe,
): Omit<
Record<keyof PhotoExif, string | undefined>,
'takenAt' | 'takenAtNaive'
> => ({
aspectRatio: getAspectRatioFromExif(data).toString(),
make: data.tags?.Make,
model: data.tags?.Model,
focalLength: data.tags?.FocalLength?.toString(),
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
lensMake: data.tags?.LensMake,
lensModel: data.tags?.LensModel,
fNumber: (
data.tags?.FNumber?.toString() ||
convertApertureValueToFNumber(data.tags?.ApertureValue)
),
iso: data.tags?.ISO?.toString() || data.tags?.ISOSpeed?.toString(),
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),
): Partial<Record<keyof PhotoExif, string | undefined>> => {
let title: string | undefined = dataExifr?.title?.value;
let caption: string | undefined;
const description: string | undefined =
data.tags?.ImageDescription ||
dataExifr?.ImageDescription ||
dataExifr?.description?.value;
const tags: string[] | undefined = dataExifr?.subject;
if (title && title !== description) {
caption = description;
} else {
title = description;
}
return {
aspectRatio: getAspectRatioFromExif(data).toString(),
make: data.tags?.Make,
model: data.tags?.Model,
focalLength: data.tags?.FocalLength?.toString(),
focalLengthIn35MmFormat: data.tags?.FocalLengthIn35mmFormat?.toString(),
lensMake: data.tags?.LensMake,
lensModel: data.tags?.LensModel,
fNumber: (
data.tags?.FNumber?.toString() ||
convertApertureValueToFNumber(data.tags?.ApertureValue)
),
takenAtNaive:
convertTimestampToNaivePostgresString(data.tags.DateTimeOriginal),
},
});
iso: data.tags?.ISO?.toString() || data.tags?.ISOSpeed?.toString(),
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(', ') },
};
};

View File

@ -1,11 +1,11 @@
import { useState } from 'react';
import { PhotoFormData, formHasTextContent } from '.';
import { useCallback, useMemo, useState } from 'react';
import { PhotoFormData, formHasExistingAiTextContent } from '.';
import useAiImageQueries from '../ai/useAiImageQueries';
import { AiAutoGeneratedField } from '../ai';
import { AiAutoGeneratedField, getAiTextFieldsToGenerate } from '../ai';
export default function usePhotoFormParent({
photoForm,
textFieldsToAutoGenerate,
textFieldsToAutoGenerate: _textFieldsToAutoGenerate = [],
imageThumbnailBase64,
}: {
photoForm?: Partial<PhotoFormData>
@ -14,8 +14,29 @@ export default function usePhotoFormParent({
}) {
const [pending, setIsPending] = useState(false);
const [updatedTitle, setUpdatedTitle] = useState('');
const [hasTextContent, setHasTextContent] =
useState(photoForm ? formHasTextContent(photoForm) : false);
const [shouldConfirmAiTextGeneration, _setShouldConfirmAiTextGeneration] =
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(
textFieldsToAutoGenerate,
@ -27,8 +48,8 @@ export default function usePhotoFormParent({
setIsPending,
updatedTitle,
setUpdatedTitle,
hasTextContent,
setHasTextContent,
shouldConfirmAiTextGeneration,
setShouldConfirmAiTextGeneration,
aiContent,
};
}

View File

@ -68,6 +68,10 @@ export interface PhotoExif {
recipeData?: string
takenAt?: string
takenAtNaive?: string
// Photo meta potentially located in EXIF/XMP data
title?: string
caption?: string
tags?: string[]
}
// Raw db insert
@ -76,7 +80,6 @@ export interface PhotoDbInsert extends PhotoExif {
url: string
extension: string
blurData?: string
title?: string
caption?: string
semanticDescription?: string
tags?: string[]

View File

@ -26,6 +26,7 @@ import {
import { PhotoDbInsert } from '.';
import { convertExifToFormData } from './form/server';
import { getColorFieldsForPhotoForm } from './color/server';
import exifr from 'exifr';
const IMAGE_WIDTH_RESIZE = 200;
const IMAGE_WIDTH_BLUR = 200;
@ -58,6 +59,7 @@ export const extractImageDataFromBlobPath = async (
const extension = getExtensionFromStorageUrl(url);
let exifData: ExifData | undefined;
let exifrData: any | undefined;
let film: FujifilmSimulation | undefined;
let recipe: FujifilmRecipe | undefined;
let blurData: string | undefined;
@ -80,6 +82,7 @@ export const extractImageDataFromBlobPath = async (
// Data for form
parser.enableBinaryFields(false);
exifData = parser.parse();
exifrData = await exifr.parse(fileBytes, { xmp: true });
// Capture film simulation for Fujifilm cameras
if (isExifForFujifilm(exifData)) {
@ -126,7 +129,7 @@ export const extractImageDataFromBlobPath = async (
url,
},
...generateBlurData && { blurData },
...convertExifToFormData(exifData, film, recipe),
...convertExifToFormData(exifData, exifrData, film, recipe),
...colorFields,
},
},