Compare commits
1 Commits
artworks
...
ai-alt-tex
| Author | SHA1 | Date | |
|---|---|---|---|
|
eb4391d1d7
|
86
src/actions/artworks/generateAltText.ts
Normal file
86
src/actions/artworks/generateAltText.ts
Normal 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();
|
||||
}
|
||||
@ -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
|
||||
10–30s.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user