Add new gallery variant

This commit is contained in:
2026-01-31 01:34:13 +01:00
parent 3ce4c9acfc
commit 88bb301e84
7 changed files with 328 additions and 21 deletions

View File

@ -0,0 +1,130 @@
"use server";
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import sharp from "sharp";
const GALLERY_TARGET_SIZE = 300;
export async function generateGalleryVariant(
artworkId: string,
opts?: { force?: boolean },
) {
const artwork = await prisma.artwork.findUnique({
where: { id: artworkId },
include: { file: true, variants: true },
});
if (!artwork || !artwork.file) {
throw new Error("Artwork or file not found");
}
const existing = artwork.variants.find((v) => v.type === "gallery");
if (existing && !opts?.force) {
return { ok: true, skipped: true, variantId: existing.id };
}
const source =
artwork.variants.find((v) => v.type === "modified") ??
artwork.variants.find((v) => v.type === "original");
if (!source?.s3Key) {
throw new Error("Missing source variant");
}
const buffer = await getImageBufferFromS3Key(source.s3Key);
const srcMeta = await sharp(buffer).metadata();
const { width, height } = srcMeta;
let resizeOptions: { width?: number; height?: number };
if (width && height) {
resizeOptions =
height < width
? { height: GALLERY_TARGET_SIZE }
: { width: GALLERY_TARGET_SIZE };
} else {
resizeOptions = { height: GALLERY_TARGET_SIZE };
}
const galleryBuffer = await sharp(buffer)
.resize({ ...resizeOptions, withoutEnlargement: true })
.toFormat("webp")
.toBuffer();
const galleryMetadata = await sharp(galleryBuffer).metadata();
const galleryKey = `gallery/${artwork.file.fileKey}.webp`;
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: galleryKey,
Body: galleryBuffer,
ContentType: "image/" + galleryMetadata.format,
}),
);
const variant = await prisma.fileVariant.upsert({
where: { artworkId_type: { artworkId: artwork.id, type: "gallery" } },
create: {
s3Key: galleryKey,
type: "gallery",
height: galleryMetadata.height ?? 0,
width: galleryMetadata.width ?? 0,
fileExtension: galleryMetadata.format,
mimeType: "image/" + galleryMetadata.format,
sizeBytes: galleryMetadata.size,
artworkId: artwork.id,
},
update: {
s3Key: galleryKey,
height: galleryMetadata.height ?? 0,
width: galleryMetadata.width ?? 0,
fileExtension: galleryMetadata.format,
mimeType: "image/" + galleryMetadata.format,
sizeBytes: galleryMetadata.size,
},
});
return { ok: true, skipped: false, variantId: variant.id };
}
export async function generateGalleryVariantsMissing(args?: {
limit?: number;
}) {
const limit = Math.min(Math.max(args?.limit ?? 20, 1), 100);
const artworks = await prisma.artwork.findMany({
where: { variants: { none: { type: "gallery" } } },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: limit,
select: { id: true },
});
const results: Array<{ artworkId: string; ok: boolean; error?: string }> = [];
for (const a of artworks) {
try {
await generateGalleryVariant(a.id);
results.push({ artworkId: a.id, ok: true });
} catch (err) {
results.push({
artworkId: a.id,
ok: false,
error: err instanceof Error ? err.message : "Failed",
});
}
}
const ok = results.filter((r) => r.ok).length;
const failed = results.length - ok;
return {
picked: artworks.length,
processed: results.length,
ok,
failed,
results,
};
}

View File

@ -0,0 +1,24 @@
"use server";
import { prisma } from "@/lib/prisma";
export type GalleryVariantStats = {
total: number;
withGallery: number;
missing: number;
};
export async function getGalleryVariantStats(): Promise<GalleryVariantStats> {
const [total, withGallery] = await Promise.all([
prisma.artwork.count(),
prisma.artwork.count({
where: { variants: { some: { type: "gallery" } } },
}),
]);
return {
total,
withGallery,
missing: total - withGallery,
};
}

View File

@ -8,7 +8,10 @@ import sharp from "sharp";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors"; import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
export async function createImageFromFile(imageFile: File, opts?: { originalName?: string, colorMode?: "inline" | "defer" | "off" }) { export async function createImageFromFile(
imageFile: File,
opts?: { originalName?: string; colorMode?: "inline" | "defer" | "off" },
) {
if (!(imageFile instanceof File)) { if (!(imageFile instanceof File)) {
console.log("No image or invalid type"); console.log("No image or invalid type");
return null; return null;
@ -29,6 +32,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
const modifiedKey = `modified/${fileKey}.webp`; const modifiedKey = `modified/${fileKey}.webp`;
const resizedKey = `resized/${fileKey}.webp`; const resizedKey = `resized/${fileKey}.webp`;
const thumbnailKey = `thumbnail/${fileKey}.webp`; const thumbnailKey = `thumbnail/${fileKey}.webp`;
const galleryKey = `gallery/${fileKey}.webp`;
const sharpData = sharp(buffer); const sharpData = sharp(buffer);
const metadata = await sharpData.metadata(); const metadata = await sharpData.metadata();
@ -40,7 +44,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
Key: originalKey, Key: originalKey,
Body: buffer, Body: buffer,
ContentType: "image/" + metadata.format, ContentType: "image/" + metadata.format,
}) }),
); );
//--- Modified file //--- Modified file
@ -53,7 +57,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
Key: modifiedKey, Key: modifiedKey,
Body: modifiedBuffer, Body: modifiedBuffer,
ContentType: "image/" + modifiedMetadata.format, ContentType: "image/" + modifiedMetadata.format,
}) }),
); );
//--- Resized file //--- Resized file
@ -62,7 +66,8 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
let resizeOptions: { width?: number; height?: number }; let resizeOptions: { width?: number; height?: number };
if (width && height) { if (width && height) {
resizeOptions = height < width ? { height: targetSize } : { width: targetSize }; resizeOptions =
height < width ? { height: targetSize } : { width: targetSize };
} else { } else {
resizeOptions = { height: targetSize }; resizeOptions = { height: targetSize };
} }
@ -80,7 +85,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
Key: resizedKey, Key: resizedKey,
Body: resizedBuffer, Body: resizedBuffer,
ContentType: "image/" + resizedMetadata.format, ContentType: "image/" + resizedMetadata.format,
}) }),
); );
//--- Thumbnail file //--- Thumbnail file
@ -88,7 +93,10 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
let thumbnailOptions: { width?: number; height?: number }; let thumbnailOptions: { width?: number; height?: number };
if (width && height) { if (width && height) {
thumbnailOptions = height < width ? { height: thumbnailTargetSize } : { width: thumbnailTargetSize }; thumbnailOptions =
height < width
? { height: thumbnailTargetSize }
: { width: thumbnailTargetSize };
} else { } else {
thumbnailOptions = { height: thumbnailTargetSize }; thumbnailOptions = { height: thumbnailTargetSize };
} }
@ -106,7 +114,36 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
Key: thumbnailKey, Key: thumbnailKey,
Body: thumbnailBuffer, Body: thumbnailBuffer,
ContentType: "image/" + thumbnailMetadata.format, ContentType: "image/" + thumbnailMetadata.format,
}) }),
);
//--- Gallery file
const galleryTargetSize = 300;
let galleryOptions: { width?: number; height?: number };
if (width && height) {
galleryOptions =
height < width
? { height: galleryTargetSize }
: { width: galleryTargetSize };
} else {
galleryOptions = { height: galleryTargetSize };
}
const galleryBuffer = await sharp(modifiedBuffer)
.resize({ ...galleryOptions, withoutEnlargement: true })
.toFormat("webp")
.toBuffer();
const galleryMetadata = await sharp(galleryBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: galleryKey,
Body: galleryBuffer,
ContentType: "image/" + galleryMetadata.format,
}),
); );
const fileRecord = await prisma.fileData.create({ const fileRecord = await prisma.fileData.create({
@ -193,6 +230,16 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
sizeBytes: thumbnailMetadata.size, sizeBytes: thumbnailMetadata.size,
artworkId: artworkRecord.id, artworkId: artworkRecord.id,
}, },
{
s3Key: galleryKey,
type: "gallery",
height: galleryMetadata.height ?? 0,
width: galleryMetadata.width ?? 0,
fileExtension: galleryMetadata.format,
mimeType: "image/" + galleryMetadata.format,
sizeBytes: galleryMetadata.size,
artworkId: artworkRecord.id,
},
], ],
}); });
@ -206,6 +253,5 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
// (nothing else to do here) // (nothing else to do here)
} }
return artworkRecord; return artworkRecord;
} }

View File

@ -28,7 +28,7 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
{item && <DeleteArtworkButton artworkId={item.id} />} {item && <DeleteArtworkButton artworkId={item.id} />}
</div> </div>
<div> <div>
{item && <ArtworkVariants variants={item.variants} />} {item && <ArtworkVariants artworkId={item.id} variants={item.variants} />}
</div> </div>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">

View File

@ -1,4 +1,5 @@
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor"; import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
import { ArtworkGalleryVariantProcessor } from "@/components/artworks/ArtworkGalleryVariantProcessor";
import { ArtworksTable } from "@/components/artworks/ArtworksTable"; import { ArtworksTable } from "@/components/artworks/ArtworksTable";
import { getArtworksPage } from "@/lib/queryArtworks"; import { getArtworksPage } from "@/lib/queryArtworks";
@ -59,6 +60,7 @@ export default async function ArtworksPage({
<h1 className="text-2xl font-bold">Artworks</h1> <h1 className="text-2xl font-bold">Artworks</h1>
{/* <ProcessArtworkColorsButton /> */} {/* <ProcessArtworkColorsButton /> */}
<ArtworkColorProcessor /> <ArtworkColorProcessor />
<ArtworkGalleryVariantProcessor />
<ArtworksTable /> <ArtworksTable />
</div> </div>
// <div> // <div>

View File

@ -0,0 +1,62 @@
"use client";
import { generateGalleryVariantsMissing } from "@/actions/artworks/generateGalleryVariant";
import { getGalleryVariantStats } from "@/actions/artworks/getGalleryVariantStats";
import { Button } from "@/components/ui/button";
import * as React from "react";
export function ArtworkGalleryVariantProcessor() {
const [stats, setStats] = React.useState<Awaited<
ReturnType<typeof getGalleryVariantStats>
> | null>(null);
const [loading, setLoading] = React.useState(false);
const [msg, setMsg] = React.useState<string | null>(null);
const refreshStats = React.useCallback(async () => {
const s = await getGalleryVariantStats();
setStats(s);
}, []);
React.useEffect(() => {
void refreshStats();
}, [refreshStats]);
const run = async () => {
setLoading(true);
setMsg(null);
try {
const res = await generateGalleryVariantsMissing({ limit: 50 });
setMsg(`Processed ${res.processed}: ${res.ok} ok, ${res.failed} failed`);
await refreshStats();
} catch (e) {
setMsg(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
};
const done = !!stats && stats.missing === 0;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<Button onClick={run} disabled={loading || done}>
{done
? "All gallery variants present"
: loading
? "Generating…"
: "Generate missing gallery variants"}
</Button>
{stats && (
<span className="text-sm text-muted-foreground">
Ready {stats.withGallery}/{stats.total}
{stats.missing > 0 && ` · Missing ${stats.missing}`}
</span>
)}
</div>
{msg && <p className="text-sm text-muted-foreground">{msg}</p>}
</div>
);
}

View File

@ -1,12 +1,19 @@
import { FileVariant } from "@/generated/prisma/client"; "use client";
import { generateGalleryVariant } from "@/actions/artworks/generateGalleryVariant";
import { Button } from "@/components/ui/button";
import type { FileVariant } from "@/generated/prisma/client";
import { formatFileSize } from "@/utils/formatFileSize"; import { formatFileSize } from "@/utils/formatFileSize";
import NextImage from "next/image"; import NextImage from "next/image";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
const ORDER: Record<string, number> = { const ORDER: Record<string, number> = {
thumbnail: 0, thumbnail: 0,
resized: 1, gallery: 1,
modified: 2, resized: 2,
original: 3, modified: 3,
original: 4,
}; };
function byVariantOrder(a: FileVariant, b: FileVariant) { function byVariantOrder(a: FileVariant, b: FileVariant) {
@ -16,18 +23,54 @@ function byVariantOrder(a: FileVariant, b: FileVariant) {
return a.type.localeCompare(b.type); return a.type.localeCompare(b.type);
} }
export default function ArtworkVariants({ variants }: { variants: FileVariant[] }) { export default function ArtworkVariants({
artworkId,
variants,
}: {
artworkId: string;
variants: FileVariant[];
}) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const hasGallery = variants.some((v) => v.type === "gallery");
const sorted = [...variants].sort(byVariantOrder); const sorted = [...variants].sort(byVariantOrder);
return ( return (
<> <>
<h2 className="font-semibold text-lg mb-2">Variants</h2> <div className="mb-2 flex items-center justify-between gap-2">
<h2 className="font-semibold text-lg">Variants</h2>
{!hasGallery ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={isPending}
onClick={() =>
startTransition(async () => {
await generateGalleryVariant(artworkId);
router.refresh();
})
}
>
{isPending ? "Generating..." : "Generate gallery"}
</Button>
) : null}
</div>
<div> <div>
{sorted.map((variant) => ( {sorted.map((variant) => (
<div key={variant.id}> <div key={variant.id}>
<div className="text-sm mb-1">{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}</div> <div className="text-sm mb-1">
{variant.type} | {variant.width}x{variant.height}px |{" "}
{variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}
</div>
{variant.s3Key && ( {variant.s3Key && (
<NextImage src={`/api/image/${variant.s3Key}`} alt={variant.s3Key} width={variant.width} height={variant.height} className="rounded shadow max-w-md" /> <NextImage
src={`/api/image/${variant.s3Key}`}
alt={variant.s3Key}
width={variant.width}
height={variant.height}
className="rounded shadow max-w-md"
/>
)} )}
</div> </div>
))} ))}