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 { updateArtwork } from "@/actions/artworks/updateArtwork";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; 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 { Input } from "@/components/ui/input";
import MultipleSelector from "@/components/ui/multiselect"; 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 { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; 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 { cn } from "@/lib/utils";
import { artworkSchema } from "@/schemas/artworks/imageSchema"; 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 { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns"; import { format } from "date-fns";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod/v4"; import type { z } from "zod/v4";
export default function EditArtworkForm({ artwork, categories, tags }: export default function EditArtworkForm({
{ artwork,
artwork: ArtworkWithRelations, categories,
categories: CategoryWithTags[] tags,
tags: ArtTag[] }: {
artwork: ArtworkWithRelations;
categories: CategoryWithTags[];
tags: ArtTag[];
}) { }) {
const router = useRouter(); const router = useRouter();
const [isGeneratingAlt, startAltTransition] = useTransition();
const form = useForm<z.infer<typeof artworkSchema>>({ const form = useForm<z.infer<typeof artworkSchema>>({
resolver: zodResolver(artworkSchema), resolver: zodResolver(artworkSchema),
defaultValues: { defaultValues: {
@ -41,19 +59,21 @@ export default function EditArtworkForm({ artwork, categories, tags }:
notes: artwork.notes || "", notes: artwork.notes || "",
month: artwork.month || undefined, month: artwork.month || undefined,
year: artwork.year || undefined, year: artwork.year || undefined,
creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined, creationDate: artwork.creationDate
categoryIds: artwork.categories?.map(cat => cat.id) ?? [], ? new Date(artwork.creationDate)
tagIds: artwork.tags?.map(tag => tag.id) ?? [], : undefined,
categoryIds: artwork.categories?.map((cat) => cat.id) ?? [],
tagIds: artwork.tags?.map((tag) => tag.id) ?? [],
newCategoryNames: [], newCategoryNames: [],
newTagNames: [] newTagNames: [],
} },
}) });
async function onSubmit(values: z.infer<typeof artworkSchema>) { async function onSubmit(values: z.infer<typeof artworkSchema>) {
const updatedArtwork = await updateArtwork(values, artwork.id) const updatedArtwork = await updateArtwork(values, artwork.id);
if (updatedArtwork) { if (updatedArtwork) {
toast.success("Artwork updated") toast.success("Artwork updated");
router.push(`/artworks`) router.push(`/artworks`);
} }
} }
@ -80,10 +100,42 @@ export default function EditArtworkForm({ artwork, categories, tags }:
name="altText" name="altText"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center justify-between gap-3">
<FormLabel>Alt Text</FormLabel> <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> <FormControl>
<Input {...field} placeholder="Alt for this image" /> <Input {...field} placeholder="Alt for this image" />
</FormControl> </FormControl>
<FormDescription>
Generates a caption from the original image. CPU-only can take
1030s.
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -95,7 +147,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea {...field} placeholder="A descriptive text to the image" /> <Textarea
{...field}
placeholder="A descriptive text to the image"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -125,9 +180,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<Input <Input
{...field} {...field}
type="number" type="number"
value={field.value ?? ''} value={field.value ?? ""}
onChange={(e) => onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value) field.onChange(
e.target.value === "" ? undefined : +e.target.value,
)
} }
/> />
</FormControl> </FormControl>
@ -145,9 +202,11 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<Input <Input
{...field} {...field}
type="number" type="number"
value={field.value ?? ''} value={field.value ?? ""}
onChange={(e) => onChange={(e) =>
field.onChange(e.target.value === '' ? undefined : +e.target.value) field.onChange(
e.target.value === "" ? undefined : +e.target.value,
)
} }
/> />
</FormControl> </FormControl>
@ -170,10 +229,12 @@ export default function EditArtworkForm({ artwork, categories, tags }:
variant="outline" variant="outline"
className={cn( className={cn(
"pl-3 text-left font-normal", "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> </Button>
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
@ -182,7 +243,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
mode="single" mode="single"
selected={field.value} selected={field.value}
onSelect={(date) => { onSelect={(date) => {
field.onChange(date) field.onChange(date);
}} }}
initialFocus initialFocus
fromYear={1990} fromYear={1990}
@ -236,17 +297,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
onChange={(options) => { onChange={(options) => {
const values = options.map((o) => o.value); 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 const newNames = values
.filter((v) => v.startsWith("__new__:")) .filter((v) => v.startsWith("__new__:"))
.map((v) => v.replace("__new__:", "").trim()) .map((v) => v.replace("__new__:", "").trim())
.filter(Boolean); .filter(Boolean);
field.onChange(existingIds); field.onChange(existingIds);
form.setValue("newCategoryNames", Array.from(new Set(newNames)), { form.setValue(
"newCategoryNames",
Array.from(new Set(newNames)),
{
shouldDirty: true, shouldDirty: true,
shouldValidate: true, shouldValidate: true,
}); },
);
}} }}
/> />
</FormControl> </FormControl>
@ -276,7 +343,8 @@ export default function EditArtworkForm({ artwork, categories, tags }:
.map((t) => { .map((t) => {
let group = "Other tags"; let group = "Other tags";
if (selectedTagIds.includes(t.id)) group = "Selected"; 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 }; return { label: t.name, value: t.id, group };
}) })
@ -301,12 +369,19 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<MultipleSelector <MultipleSelector
options={tagOptions} options={tagOptions}
groupBy="group" groupBy="group"
groupOrder={["Selected", "From selected categories", "Other tags"]} groupOrder={[
"Selected",
"From selected categories",
"Other tags",
]}
showSelectedInDropdown showSelectedInDropdown
placeholder="Select or type to create tags" placeholder="Select or type to create tags"
hidePlaceholderWhenSelected hidePlaceholderWhenSelected
selectFirstItem selectFirstItem
value={[...selectedExistingOptions, ...selectedNewOptions]} value={[
...selectedExistingOptions,
...selectedNewOptions,
]}
creatable creatable
createOption={(raw) => ({ createOption={(raw) => ({
value: `__new__:${raw}`, value: `__new__:${raw}`,
@ -316,17 +391,23 @@ export default function EditArtworkForm({ artwork, categories, tags }:
onChange={(options) => { onChange={(options) => {
const values = options.map((o) => o.value); 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 const newNames = values
.filter((v) => v.startsWith("__new__:")) .filter((v) => v.startsWith("__new__:"))
.map((v) => v.replace("__new__:", "").trim()) .map((v) => v.replace("__new__:", "").trim())
.filter(Boolean); .filter(Boolean);
field.onChange(existingIds); field.onChange(existingIds);
form.setValue("newTagNames", Array.from(new Set(newNames)), { form.setValue(
"newTagNames",
Array.from(new Set(newNames)),
{
shouldDirty: true, shouldDirty: true,
shouldValidate: true, shouldValidate: true,
}); },
);
}} }}
/> />
</FormControl> </FormControl>
@ -347,7 +428,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormDescription></FormDescription> <FormDescription></FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -359,10 +443,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4"> <FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>NSFW</FormLabel> <FormLabel>NSFW</FormLabel>
<FormDescription>This image contains sensitive or adult content.</FormDescription> <FormDescription>
This image contains sensitive or adult content.
</FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -374,10 +463,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4"> <FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Publish</FormLabel> <FormLabel>Publish</FormLabel>
<FormDescription>Will this image be published.</FormDescription> <FormDescription>
Will this image be published.
</FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -389,10 +483,15 @@ export default function EditArtworkForm({ artwork, categories, tags }:
<FormItem className="flex items-center justify-between rounded-lg border p-4"> <FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Set as header image</FormLabel> <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> </div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
@ -444,7 +543,13 @@ export default function EditArtworkForm({ artwork, categories, tags }:
/> */} /> */}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Button type="submit">Submit</Button> <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> </div>
</form> </form>
</Form> </Form>