diff --git a/src/actions/artworks/generateGalleryVariant.ts b/src/actions/artworks/generateGalleryVariant.ts new file mode 100644 index 0000000..904d51e --- /dev/null +++ b/src/actions/artworks/generateGalleryVariant.ts @@ -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, + }; +} diff --git a/src/actions/artworks/getGalleryVariantStats.ts b/src/actions/artworks/getGalleryVariantStats.ts new file mode 100644 index 0000000..1fe8664 --- /dev/null +++ b/src/actions/artworks/getGalleryVariantStats.ts @@ -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 { + const [total, withGallery] = await Promise.all([ + prisma.artwork.count(), + prisma.artwork.count({ + where: { variants: { some: { type: "gallery" } } }, + }), + ]); + + return { + total, + withGallery, + missing: total - withGallery, + }; +} diff --git a/src/actions/uploads/createImageFromFile.ts b/src/actions/uploads/createImageFromFile.ts index 1f1c930..7ba6c53 100644 --- a/src/actions/uploads/createImageFromFile.ts +++ b/src/actions/uploads/createImageFromFile.ts @@ -8,7 +8,10 @@ import sharp from "sharp"; import { v4 as uuidv4 } from "uuid"; 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)) { console.log("No image or invalid type"); return null; @@ -29,6 +32,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName const modifiedKey = `modified/${fileKey}.webp`; const resizedKey = `resized/${fileKey}.webp`; const thumbnailKey = `thumbnail/${fileKey}.webp`; + const galleryKey = `gallery/${fileKey}.webp`; const sharpData = sharp(buffer); const metadata = await sharpData.metadata(); @@ -40,7 +44,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName Key: originalKey, Body: buffer, ContentType: "image/" + metadata.format, - }) + }), ); //--- Modified file @@ -53,7 +57,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName Key: modifiedKey, Body: modifiedBuffer, ContentType: "image/" + modifiedMetadata.format, - }) + }), ); //--- Resized file @@ -62,7 +66,8 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName let resizeOptions: { width?: number; height?: number }; if (width && height) { - resizeOptions = height < width ? { height: targetSize } : { width: targetSize }; + resizeOptions = + height < width ? { height: targetSize } : { width: targetSize }; } else { resizeOptions = { height: targetSize }; } @@ -80,7 +85,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName Key: resizedKey, Body: resizedBuffer, ContentType: "image/" + resizedMetadata.format, - }) + }), ); //--- Thumbnail file @@ -88,7 +93,10 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName let thumbnailOptions: { width?: number; height?: number }; if (width && height) { - thumbnailOptions = height < width ? { height: thumbnailTargetSize } : { width: thumbnailTargetSize }; + thumbnailOptions = + height < width + ? { height: thumbnailTargetSize } + : { width: thumbnailTargetSize }; } else { thumbnailOptions = { height: thumbnailTargetSize }; } @@ -106,7 +114,36 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName Key: thumbnailKey, Body: thumbnailBuffer, 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({ @@ -193,6 +230,16 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName sizeBytes: thumbnailMetadata.size, 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) } - return artworkRecord; -} \ No newline at end of file +} diff --git a/src/app/(admin)/artworks/[id]/page.tsx b/src/app/(admin)/artworks/[id]/page.tsx index d41693d..988a1c2 100644 --- a/src/app/(admin)/artworks/[id]/page.tsx +++ b/src/app/(admin)/artworks/[id]/page.tsx @@ -28,7 +28,7 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{ {item && }
- {item && } + {item && }
@@ -45,4 +45,4 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
); -} \ No newline at end of file +} diff --git a/src/app/(admin)/artworks/page.tsx b/src/app/(admin)/artworks/page.tsx index 8ee8d59..a303f73 100644 --- a/src/app/(admin)/artworks/page.tsx +++ b/src/app/(admin)/artworks/page.tsx @@ -1,4 +1,5 @@ import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor"; +import { ArtworkGalleryVariantProcessor } from "@/components/artworks/ArtworkGalleryVariantProcessor"; import { ArtworksTable } from "@/components/artworks/ArtworksTable"; import { getArtworksPage } from "@/lib/queryArtworks"; @@ -59,6 +60,7 @@ export default async function ArtworksPage({

Artworks

{/* */} + //
@@ -89,4 +91,4 @@ export default async function ArtworksPage({ //
// ); -} \ No newline at end of file +} diff --git a/src/components/artworks/ArtworkGalleryVariantProcessor.tsx b/src/components/artworks/ArtworkGalleryVariantProcessor.tsx new file mode 100644 index 0000000..c4797e8 --- /dev/null +++ b/src/components/artworks/ArtworkGalleryVariantProcessor.tsx @@ -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 + > | null>(null); + const [loading, setLoading] = React.useState(false); + const [msg, setMsg] = React.useState(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 ( +
+
+ + + {stats && ( + + Ready {stats.withGallery}/{stats.total} + {stats.missing > 0 && ` · Missing ${stats.missing}`} + + )} +
+ + {msg &&

{msg}

} +
+ ); +} diff --git a/src/components/artworks/single/ArtworkVariants.tsx b/src/components/artworks/single/ArtworkVariants.tsx index 90fd3d2..9467d4a 100644 --- a/src/components/artworks/single/ArtworkVariants.tsx +++ b/src/components/artworks/single/ArtworkVariants.tsx @@ -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 NextImage from "next/image"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; const ORDER: Record = { thumbnail: 0, - resized: 1, - modified: 2, - original: 3, + gallery: 1, + resized: 2, + modified: 3, + original: 4, }; function byVariantOrder(a: FileVariant, b: FileVariant) { @@ -16,22 +23,58 @@ function byVariantOrder(a: FileVariant, b: FileVariant) { 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); return ( <> -

Variants

+
+

Variants

+ {!hasGallery ? ( + + ) : null} +
{sorted.map((variant) => (
-
{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}
+
+ {variant.type} | {variant.width}x{variant.height}px |{" "} + {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"} +
{variant.s3Key && ( - + )}
))}
); -} \ No newline at end of file +}