diff --git a/src/actions/artworks/generateAltText.ts b/src/actions/artworks/generateAltText.ts new file mode 100644 index 0000000..c8438db --- /dev/null +++ b/src/actions/artworks/generateAltText.ts @@ -0,0 +1,86 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; +import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3"; + +export async function generateAltTextForArtwork( + artworkId: string, + prompt?: string, +) { + const serviceUrl = process.env.ALT_TEXT_SERVICE_URL; + if (!serviceUrl) { + throw new Error("ALT_TEXT_SERVICE_URL is not set"); + } + + const artwork = await prisma.artwork.findUnique({ + where: { id: artworkId }, + select: { + variants: { + where: { type: "original" }, + select: { s3Key: true }, + take: 1, + }, + }, + }); + + const original = artwork?.variants?.[0]; + if (!original?.s3Key) { + throw new Error("Original image variant not found"); + } + + const buffer = await getImageBufferFromS3Key(original.s3Key); + + const formData = new FormData(); + const bytes = new Uint8Array(buffer); + formData.append( + "image", + new Blob([bytes], { type: "image/jpeg" }), + "artwork.jpg", + ); + if (prompt && prompt.trim().length > 0) { + formData.append("prompt", prompt.trim()); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 120000); + + let response: Response | null = null; + let lastError: unknown = null; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + response = await fetch(`${serviceUrl}/caption`, { + method: "POST", + body: formData, + signal: controller.signal, + }); + break; + } catch (err) { + lastError = err; + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 750)); + } + } + } + + clearTimeout(timeoutId); + + if (!response) { + throw new Error(`Alt text service failed: ${String(lastError)}`); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Alt text service failed: ${text}`); + } + + const data = (await response.json()) as { altText?: string; error?: string }; + if (data.error) { + throw new Error(`Alt text service error: ${data.error}`); + } + if (!data.altText) { + throw new Error(`Alt text service returned no result: ${JSON.stringify(data)}`); + } + + return data.altText.trim(); +} diff --git a/src/components/artworks/single/EditArtworkForm.tsx b/src/components/artworks/single/EditArtworkForm.tsx index c135e11..6f823ea 100644 --- a/src/components/artworks/single/EditArtworkForm.tsx +++ b/src/components/artworks/single/EditArtworkForm.tsx @@ -1,32 +1,50 @@ -"use client" +"use client"; +import { generateAltTextForArtwork } from "@/actions/artworks/generateAltText"; import { updateArtwork } from "@/actions/artworks/updateArtwork"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import MultipleSelector from "@/components/ui/multiselect"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; -import { ArtTag } from "@/generated/prisma/client"; +import type { ArtTag } from "@/generated/prisma/client"; import { cn } from "@/lib/utils"; import { artworkSchema } from "@/schemas/artworks/imageSchema"; -import { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork"; +import type { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork"; import { zodResolver } from "@hookform/resolvers/zod"; import { format } from "date-fns"; import { useRouter } from "next/navigation"; +import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { z } from "zod/v4"; +import type { z } from "zod/v4"; -export default function EditArtworkForm({ artwork, categories, tags }: - { - artwork: ArtworkWithRelations, - categories: CategoryWithTags[] - tags: ArtTag[] - }) { +export default function EditArtworkForm({ + artwork, + categories, + tags, +}: { + artwork: ArtworkWithRelations; + categories: CategoryWithTags[]; + tags: ArtTag[]; +}) { const router = useRouter(); + const [isGeneratingAlt, startAltTransition] = useTransition(); const form = useForm>({ resolver: zodResolver(artworkSchema), defaultValues: { @@ -41,19 +59,21 @@ export default function EditArtworkForm({ artwork, categories, tags }: notes: artwork.notes || "", month: artwork.month || undefined, year: artwork.year || undefined, - creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined, - categoryIds: artwork.categories?.map(cat => cat.id) ?? [], - tagIds: artwork.tags?.map(tag => tag.id) ?? [], + creationDate: artwork.creationDate + ? new Date(artwork.creationDate) + : undefined, + categoryIds: artwork.categories?.map((cat) => cat.id) ?? [], + tagIds: artwork.tags?.map((tag) => tag.id) ?? [], newCategoryNames: [], - newTagNames: [] - } - }) + newTagNames: [], + }, + }); async function onSubmit(values: z.infer) { - const updatedArtwork = await updateArtwork(values, artwork.id) + const updatedArtwork = await updateArtwork(values, artwork.id); if (updatedArtwork) { - toast.success("Artwork updated") - router.push(`/artworks`) + toast.success("Artwork updated"); + router.push(`/artworks`); } } @@ -80,10 +100,42 @@ export default function EditArtworkForm({ artwork, categories, tags }: name="altText" render={({ field }) => ( - Alt Text +
+ Alt Text + +
+ + Generates a caption from the original image. CPU-only can take + 10–30s. +
)} @@ -95,7 +147,10 @@ export default function EditArtworkForm({ artwork, categories, tags }: Description -