1 Commits

Author SHA1 Message Date
eb4391d1d7 Add local AI vition option for alt text 2026-02-01 15:07:08 +01:00
2 changed files with 244 additions and 53 deletions

View File

@ -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();
}

View File

@ -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<z.infer<typeof artworkSchema>>({
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<typeof artworkSchema>) {
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 }) => (
<FormItem>
<div className="flex items-center justify-between gap-3">
<FormLabel>Alt Text</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
disabled={isGeneratingAlt}
onClick={() =>
startAltTransition(async () => {
try {
const alt = await generateAltTextForArtwork(
artwork.id,
field.value,
);
form.setValue("altText", alt, {
shouldDirty: true,
shouldValidate: true,
});
toast.success("Alt text generated");
} catch (err) {
console.error(err);
toast.error("Alt text generation failed");
}
})
}
>
{isGeneratingAlt ? "Generating..." : "Generate"}
</Button>
</div>
<FormControl>
<Input {...field} placeholder="Alt for this image" />
</FormControl>
<FormDescription>
Generates a caption from the original image. CPU-only can take
1030s.
</FormDescription>
<FormMessage />
</FormItem>
)}
@ -95,7 +147,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea {...field} placeholder="A descriptive text to the image" />
<Textarea
{...field}
placeholder="A descriptive text to the image"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -125,9 +180,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<Input
{...field}
type="number"
value={field.value ?? ''}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value)
field.onChange(
e.target.value === "" ? undefined : +e.target.value,
)
}
/>
</FormControl>
@ -145,9 +202,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<Input
{...field}
type="number"
value={field.value ?? ''}
value={field.value ?? ""}
onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value)
field.onChange(
e.target.value === "" ? undefined : +e.target.value,
)
}
/>
</FormControl>
@ -170,10 +229,12 @@ export default function EditArtworkForm({ artwork, categories, tags }:
variant="outline"
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
!field.value && "text-muted-foreground",
)}
>
{field.value ? format(field.value, "PPP") : "Pick a date"}
{field.value
? format(field.value, "PPP")
: "Pick a date"}
</Button>
</FormControl>
</PopoverTrigger>
@ -182,7 +243,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
mode="single"
selected={field.value}
onSelect={(date) => {
field.onChange(date)
field.onChange(date);
}}
initialFocus
fromYear={1990}
@ -236,17 +297,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
onChange={(options) => {
const values = options.map((o) => o.value);
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
const existingIds = values.filter(
(v) => !v.startsWith("__new__:"),
);
const newNames = values
.filter((v) => v.startsWith("__new__:"))
.map((v) => v.replace("__new__:", "").trim())
.filter(Boolean);
field.onChange(existingIds);
form.setValue("newCategoryNames", Array.from(new Set(newNames)), {
form.setValue(
"newCategoryNames",
Array.from(new Set(newNames)),
{
shouldDirty: true,
shouldValidate: true,
});
},
);
}}
/>
</FormControl>
@ -276,7 +343,8 @@ export default function EditArtworkForm({ artwork, categories, tags }:
.map((t) => {
let group = "Other tags";
if (selectedTagIds.includes(t.id)) group = "Selected";
else if (preferredTagIds.has(t.id)) group = "From selected categories";
else if (preferredTagIds.has(t.id))
group = "From selected categories";
return { label: t.name, value: t.id, group };
})
@ -301,12 +369,19 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<MultipleSelector
options={tagOptions}
groupBy="group"
groupOrder={["Selected", "From selected categories", "Other tags"]}
groupOrder={[
"Selected",
"From selected categories",
"Other tags",
]}
showSelectedInDropdown
placeholder="Select or type to create tags"
hidePlaceholderWhenSelected
selectFirstItem
value={[...selectedExistingOptions, ...selectedNewOptions]}
value={[
...selectedExistingOptions,
...selectedNewOptions,
]}
creatable
createOption={(raw) => ({
value: `__new__:${raw}`,
@ -316,17 +391,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
onChange={(options) => {
const values = options.map((o) => o.value);
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
const existingIds = values.filter(
(v) => !v.startsWith("__new__:"),
);
const newNames = values
.filter((v) => v.startsWith("__new__:"))
.map((v) => v.replace("__new__:", "").trim())
.filter(Boolean);
field.onChange(existingIds);
form.setValue("newTagNames", Array.from(new Set(newNames)), {
form.setValue(
"newTagNames",
Array.from(new Set(newNames)),
{
shouldDirty: true,
shouldValidate: true,
});
},
);
}}
/>
</FormControl>
@ -347,7 +428,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormDescription></FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -359,10 +443,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>NSFW</FormLabel>
<FormDescription>This image contains sensitive or adult content.</FormDescription>
<FormDescription>
This image contains sensitive or adult content.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -374,10 +463,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Publish</FormLabel>
<FormDescription>Will this image be published.</FormDescription>
<FormDescription>
Will this image be published.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -389,10 +483,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Set as header image</FormLabel>
<FormDescription>Will be the main banner image. Choose a fitting one.</FormDescription>
<FormDescription>
Will be the main banner image. Choose a fitting one.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@ -444,10 +543,16 @@ export default function EditArtworkForm({ artwork, categories, tags }:
/> */}
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
<Button
type="reset"
variant="secondary"
onClick={() => router.back()}
>
Cancel
</Button>
</div>
</form>
</Form>
</div >
</div>
);
}