From ededf3df0658bd86a62090a0a9a1534d78209123 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 25 Dec 2025 09:24:27 +0100 Subject: [PATCH] Add portfolio thingies --- .../20251225010559_artwork_8/migration.sql | 5 + .../20251225013027_artwork_9/migration.sql | 7 + prisma/schema.prisma | 8 + src/actions/artworks/generateArtworkColors.ts | 176 +++++++++--------- src/actions/colors/getArtworkColorStats.ts | 39 ++++ .../colors/processPendingArtworkColors.ts | 70 +++++++ src/actions/uploads/createBulkImages.ts | 2 +- src/actions/uploads/createImage.ts | 2 +- src/actions/uploads/createImageFromFile.ts | 15 +- src/app/artworks/page.tsx | 3 + .../artworks/ArtworkColorProcessor.tsx | 65 +++++++ src/components/global/TopNav.tsx | 30 ++- src/utils/getImageBufferFromS3.ts | 13 +- 13 files changed, 332 insertions(+), 103 deletions(-) create mode 100644 prisma/migrations/20251225010559_artwork_8/migration.sql create mode 100644 prisma/migrations/20251225013027_artwork_9/migration.sql create mode 100644 src/actions/colors/getArtworkColorStats.ts create mode 100644 src/actions/colors/processPendingArtworkColors.ts create mode 100644 src/components/artworks/ArtworkColorProcessor.tsx diff --git a/prisma/migrations/20251225010559_artwork_8/migration.sql b/prisma/migrations/20251225010559_artwork_8/migration.sql new file mode 100644 index 0000000..e34ffcc --- /dev/null +++ b/prisma/migrations/20251225010559_artwork_8/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "Artwork_published_sortKey_id_idx" ON "Artwork"("published", "sortKey", "id"); + +-- CreateIndex +CREATE INDEX "Artwork_year_published_sortKey_id_idx" ON "Artwork"("year", "published", "sortKey", "id"); diff --git a/prisma/migrations/20251225013027_artwork_9/migration.sql b/prisma/migrations/20251225013027_artwork_9/migration.sql new file mode 100644 index 0000000..ba862ff --- /dev/null +++ b/prisma/migrations/20251225013027_artwork_9/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "Artwork" ADD COLUMN "colorError" TEXT, +ADD COLUMN "colorStatus" TEXT NOT NULL DEFAULT 'PENDING', +ADD COLUMN "colorsGeneratedAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "Artwork_colorStatus_idx" ON "Artwork"("colorStatus"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bac4c71..b117bf6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,10 @@ model Artwork { published Boolean @default(false) setAsHeader Boolean @default(false) + colorStatus String @default("PENDING") // PENDING | PROCESSING | READY | FAILED + colorError String? + colorsGeneratedAt DateTime? + fileId String @unique file FileData @relation(fields: [fileId], references: [id]) @@ -50,6 +54,10 @@ model Artwork { colors ArtworkColor[] tags ArtTag[] variants FileVariant[] + + @@index([colorStatus]) + @@index([published, sortKey, id]) + @@index([year, published, sortKey, id]) } model Album { diff --git a/src/actions/artworks/generateArtworkColors.ts b/src/actions/artworks/generateArtworkColors.ts index c17210d..8a4b757 100644 --- a/src/actions/artworks/generateArtworkColors.ts +++ b/src/actions/artworks/generateArtworkColors.ts @@ -1,8 +1,8 @@ -"use server" +"use server"; import { prisma } from "@/lib/prisma"; import { VibrantSwatch } from "@/types/VibrantSwatch"; -import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; +import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3"; import { generateColorName, rgbToHex } from "@/utils/uploadHelper"; import { converter, parse } from "culori"; import { Vibrant } from "node-vibrant/node"; @@ -11,19 +11,14 @@ const toOklab = converter("oklab"); const A_MIN = -0.5, A_MAX = 0.5; const B_MIN = -0.5, B_MAX = 0.5; -function clamp01(x: number) { - return Math.max(0, Math.min(1, x)); -} - -function norm(x: number, lo: number, hi: number) { - return clamp01((x - lo) / (hi - lo)); -} +function clamp01(x: number) { return Math.max(0, Math.min(1, x)); } +function norm(x: number, lo: number, hi: number) { return clamp01((x - lo) / (hi - lo)); } function hilbertIndex15(x01: number, y01: number): number { let x = Math.floor(clamp01(x01) * 32767); let y = Math.floor(clamp01(y01) * 32767); let index = 0; - for (let s = 1 << 14; s > 0; s >>= 1) { // start at bit 14 + for (let s = 1 << 14; s > 0; s >>= 1) { const rx = (x & s) ? 1 : 0; const ry = (y & s) ? 1 : 0; index += s * s * ((3 * rx) ^ ry); @@ -36,7 +31,6 @@ function hilbertIndex15(x01: number, y01: number): number { } function centroidFromPaletteHexes(hexByType: Record) { - // Tweak weights as you like. Biasing toward Vibrant keeps things “readable”. const weights: Record = { Vibrant: 0.7, Muted: 0.15, @@ -46,7 +40,6 @@ function centroidFromPaletteHexes(hexByType: Record) LightMuted: 0.01, }; - // Ensure we have at least a vibrant color to anchor on const fallbackHex = hexByType["Vibrant"] || hexByType["Muted"] || @@ -57,8 +50,7 @@ function centroidFromPaletteHexes(hexByType: Record) let L = 0, A = 0, B = 0, W = 0; - const entries = Object.entries(weights); - for (const [type, w] of entries) { + for (const [type, w] of Object.entries(weights)) { const hex = hexByType[type] ?? fallbackHex; if (!hex || w <= 0) continue; const c = toOklab(parse(hex)); @@ -66,84 +58,92 @@ function centroidFromPaletteHexes(hexByType: Record) L += c.l * w; A += c.a * w; B += c.b * w; W += w; } - if (W === 0) { - // Should be rare; default to mid-gray - return { l: 0.5, a: 0, b: 0 }; - } + if (W === 0) return { l: 0.5, a: 0, b: 0 }; return { l: L / W, a: A / W, b: B / W }; } -export async function generateArtworkColors(artworkId: string, fileKey: string, fileType?: string) { - const buffer = await getImageBufferFromS3(fileKey, fileType); - const palette = await Vibrant.from(buffer).getPalette(); - - const vibrantHexes = Object.entries(palette).map(([key, swatch]) => { - const castSwatch = swatch as VibrantSwatch | null; - const rgb = castSwatch?._rgb; - const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); - return { type: key, hex }; - }); - - for (const { type, hex } of vibrantHexes) { - if (!hex) continue; - - const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); - const name = generateColorName(hex); - - const color = await prisma.color.upsert({ - where: { name }, - create: { - name, - type, - hex, - red: r, - green: g, - blue: b, - }, - update: { - hex, - red: r, - green: g, - blue: b, - }, - }); - - await prisma.artworkColor.upsert({ - where: { - artworkId_type: { - artworkId, - type, - }, - }, - create: { - artworkId, - colorId: color.id, - type, - }, - update: { - colorId: color.id, - }, - }); - } - - // 2) Compute OKLab centroid → Hilbert sortKey (incremental-safe) - const hexByType: Record = Object.fromEntries( - vibrantHexes.map(({ type, hex }) => [type, hex]) - ); - - const { l, a, b } = centroidFromPaletteHexes(hexByType); - const ax = norm(a, A_MIN, A_MAX); - const bx = norm(b, B_MIN, B_MAX); - const sortKey = hilbertIndex15(ax, bx); - - // 3) Store on the Image (plus optional OKLab fields) +/** + * Generates ArtworkColor rows + sortKey/okLab fields for an artwork. + * Safe to call multiple times. + */ +export async function generateArtworkColorsForArtwork(artworkId: string) { + // mark processing (optional but recommended) await prisma.artwork.update({ where: { id: artworkId }, - data: { sortKey, okLabL: l, okLabA: a, okLabB: b }, + data: { colorStatus: "PROCESSING", colorError: null }, }); - return await prisma.artworkColor.findMany({ - where: { artworkId }, - include: { color: true }, - }); -} \ No newline at end of file + try { + const artwork = await prisma.artwork.findUnique({ + where: { id: artworkId }, + select: { + file: { select: { fileKey: true } }, + variants: { where: { type: "original" }, select: { s3Key: true }, take: 1 }, + }, + }); + if (!artwork) throw new Error("Artwork not found"); + + const original = artwork.variants[0]; + if (!original?.s3Key) throw new Error("Missing modified variant s3Key"); + + const buffer = await getImageBufferFromS3Key(original.s3Key); + + const palette = await Vibrant.from(buffer).getPalette(); + + const vibrantHexes = Object.entries(palette).map(([key, swatch]) => { + const castSwatch = swatch as VibrantSwatch | null; + const rgb = castSwatch?._rgb; + const hex = castSwatch?.hex || (rgb ? rgbToHex(rgb) : undefined); + return { type: key, hex }; + }); + + for (const { type, hex } of vibrantHexes) { + if (!hex) continue; + + const [r, g, b] = hex.match(/\w\w/g)!.map((h) => parseInt(h, 16)); + const name = generateColorName(hex); + + const color = await prisma.color.upsert({ + where: { name }, + create: { name, type, hex, red: r, green: g, blue: b }, + update: { hex, red: r, green: g, blue: b }, + }); + + await prisma.artworkColor.upsert({ + where: { artworkId_type: { artworkId, type } }, + create: { artworkId, colorId: color.id, type }, + update: { colorId: color.id }, + }); + } + + const hexByType: Record = Object.fromEntries( + vibrantHexes.map(({ type, hex }) => [type, hex]) + ); + + const { l, a, b } = centroidFromPaletteHexes(hexByType); + const sortKey = hilbertIndex15(norm(a, A_MIN, A_MAX), norm(b, B_MIN, B_MAX)); + + await prisma.artwork.update({ + where: { id: artworkId }, + data: { + sortKey, + okLabL: l, + okLabA: a, + okLabB: b, + colorStatus: "READY", + colorsGeneratedAt: new Date(), + }, + }); + + return { ok: true as const, sortKey }; + } catch (e) { + await prisma.artwork.update({ + where: { id: artworkId }, + data: { + colorStatus: "FAILED", + colorError: e instanceof Error ? e.message : "Color generation failed", + }, + }); + return { ok: false as const, error: e instanceof Error ? e.message : "Color generation failed" }; + } +} diff --git a/src/actions/colors/getArtworkColorStats.ts b/src/actions/colors/getArtworkColorStats.ts new file mode 100644 index 0000000..a188bd3 --- /dev/null +++ b/src/actions/colors/getArtworkColorStats.ts @@ -0,0 +1,39 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; + +export type ArtworkColorStats = { + total: number; + ready: number; + pending: number; + processing: number; + failed: number; + missingSortKey: number; +}; + +export async function getArtworkColorStats(): Promise { + const [ + total, + ready, + pending, + processing, + failed, + missingSortKey, + ] = await Promise.all([ + prisma.artwork.count(), + prisma.artwork.count({ where: { colorStatus: "READY" } }), + prisma.artwork.count({ where: { colorStatus: "PENDING" } }), + prisma.artwork.count({ where: { colorStatus: "PROCESSING" } }), + prisma.artwork.count({ where: { colorStatus: "FAILED" } }), + prisma.artwork.count({ where: { sortKey: null } }), + ]); + + return { + total, + ready, + pending, + processing, + failed, + missingSortKey, + }; +} diff --git a/src/actions/colors/processPendingArtworkColors.ts b/src/actions/colors/processPendingArtworkColors.ts new file mode 100644 index 0000000..c6e1e27 --- /dev/null +++ b/src/actions/colors/processPendingArtworkColors.ts @@ -0,0 +1,70 @@ +"use server"; + +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/prisma"; +import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors"; + +export type ProcessColorsResult = { + picked: number; + processed: number; + ok: number; + failed: number; + results: Array<{ artworkId: string; ok: boolean; error?: string }>; +}; + +export async function processPendingArtworkColors(args?: { + limit?: number; + includeFailed?: boolean; + includeMissingSortKey?: boolean; +}): Promise { + const limit = Math.min(Math.max(args?.limit ?? 10, 1), 50); + const includeFailed = args?.includeFailed ?? true; + const includeMissingSortKey = args?.includeMissingSortKey ?? true; + + // Build OR as a mutable array with explicit Prisma typing + const or: Prisma.ArtworkWhereInput[] = []; + + if (includeFailed) { + or.push({ colorStatus: { in: ["PENDING", "FAILED"] } }); + } else { + or.push({ colorStatus: { equals: "PENDING" } }); + } + + if (includeMissingSortKey) { + or.push({ sortKey: null }); + } + + const where: Prisma.ArtworkWhereInput = { + OR: or, + variants: { some: { type: "modified" } }, + }; + + const picked = await prisma.artwork.findMany({ + where, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit, + select: { id: true }, + }); + + const results: ProcessColorsResult["results"] = []; + + for (const a of picked) { + const r = await generateArtworkColorsForArtwork(a.id); + results.push({ + artworkId: a.id, + ok: r.ok, + ...(r.ok ? {} : { error: r.error }), + }); + } + + const ok = results.filter((r) => r.ok).length; + const failed = results.length - ok; + + return { + picked: picked.length, + processed: results.length, + ok, + failed, + results, + }; +} diff --git a/src/actions/uploads/createBulkImages.ts b/src/actions/uploads/createBulkImages.ts index a688304..b38e0dd 100644 --- a/src/actions/uploads/createBulkImages.ts +++ b/src/actions/uploads/createBulkImages.ts @@ -21,7 +21,7 @@ export async function createImagesBulk(formData: FormData): Promise) { const imageFile = values.file[0]; - return createImageFromFile(imageFile); + return createImageFromFile(imageFile, { colorMode: "inline" }); } /* diff --git a/src/actions/uploads/createImageFromFile.ts b/src/actions/uploads/createImageFromFile.ts index 19b7aac..1f1c930 100644 --- a/src/actions/uploads/createImageFromFile.ts +++ b/src/actions/uploads/createImageFromFile.ts @@ -6,8 +6,9 @@ import { PutObjectCommand } from "@aws-sdk/client-s3"; import "dotenv/config"; import sharp from "sharp"; import { v4 as uuidv4 } from "uuid"; +import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors"; -export async function createImageFromFile(imageFile: File, opts?: { originalName?: string }) { +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; @@ -126,6 +127,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName slug: artworkSlug, creationDate: lastModified, fileId: fileRecord.id, + colorStatus: "PENDING", }, }); @@ -194,5 +196,16 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName ], }); + const mode = opts?.colorMode ?? "defer"; + + if (mode === "inline") { + // blocks, but guarantees sortKey is ready immediately + await generateArtworkColorsForArtwork(artworkRecord.id); + } else if (mode === "defer") { + // mark pending; a separate job will process these + // (nothing else to do here) + } + + return artworkRecord; } \ No newline at end of file diff --git a/src/app/artworks/page.tsx b/src/app/artworks/page.tsx index a9cd47f..8ee8d59 100644 --- a/src/app/artworks/page.tsx +++ b/src/app/artworks/page.tsx @@ -1,3 +1,4 @@ +import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor"; import { ArtworksTable } from "@/components/artworks/ArtworksTable"; import { getArtworksPage } from "@/lib/queryArtworks"; @@ -56,6 +57,8 @@ export default async function ArtworksPage({ return (

Artworks

+ {/* */} +
//
diff --git a/src/components/artworks/ArtworkColorProcessor.tsx b/src/components/artworks/ArtworkColorProcessor.tsx new file mode 100644 index 0000000..5671796 --- /dev/null +++ b/src/components/artworks/ArtworkColorProcessor.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { getArtworkColorStats } from "@/actions/colors/getArtworkColorStats"; +import { processPendingArtworkColors } from "@/actions/colors/processPendingArtworkColors"; +import { Button } from "@/components/ui/button"; +import * as React from "react"; + +export function ArtworkColorProcessor() { + const [stats, setStats] = React.useState> | null>(null); + const [loading, setLoading] = React.useState(false); + const [msg, setMsg] = React.useState(null); + + const refreshStats = async () => { + const s = await getArtworkColorStats(); + setStats(s); + }; + + React.useEffect(() => { + void refreshStats(); + }, []); + + const run = async () => { + setLoading(true); + setMsg(null); + try { + const res = await processPendingArtworkColors({ + limit: 50, + includeFailed: true, + includeMissingSortKey: true, + }); + 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.pending === 0 && + stats.processing === 0 && + stats.failed === 0 && + stats.missingSortKey === 0; + + return ( +
+
+ + + {stats && ( + + Ready {stats.ready}/{stats.total} + {stats.failed > 0 && ` · Failed ${stats.failed}`} + + )} +
+ + {msg &&

{msg}

} +
+ ); +} diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index 1560f02..58d2dd0 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -25,6 +25,17 @@ const artworkItems = [ } ] +const commissionItems = [ + { + title: "Commissions", + href: "/commissions", + }, + { + title: "Types", + href: "/commissions/types", + } +] + // const portfolioItems = [ // { // title: "Images", @@ -104,9 +115,22 @@ export default function TopNav() { - - Commissions - + Commissions + +
    + {commissionItems.map((item) => ( +
  • + + +
    {item.title}
    +

    +

    + +
    +
  • + ))} +
+
{/* diff --git a/src/utils/getImageBufferFromS3.ts b/src/utils/getImageBufferFromS3.ts index 0c4615b..93e89e9 100644 --- a/src/utils/getImageBufferFromS3.ts +++ b/src/utils/getImageBufferFromS3.ts @@ -1,23 +1,18 @@ import { s3 } from "@/lib/s3"; import { GetObjectCommand } from "@aws-sdk/client-s3"; -import "dotenv/config"; import { Readable } from "stream"; -export async function getImageBufferFromS3(fileKey: string, fileType?: string): Promise { - // const type = fileType ? fileType.split("/")[1] : "webp"; - +export async function getImageBufferFromS3Key(s3Key: string): Promise { const command = new GetObjectCommand({ - Bucket: `${process.env.BUCKET_NAME}`, - Key: `original/${fileKey}.${fileType}`, + Bucket: process.env.BUCKET_NAME!, + Key: s3Key, }); const response = await s3.send(command); const stream = response.Body as Readable; const chunks: Uint8Array[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } + for await (const chunk of stream) chunks.push(chunk); return Buffer.concat(chunks); } \ No newline at end of file