Add portfolio thingies

This commit is contained in:
2025-12-25 09:24:27 +01:00
parent ee454261cb
commit ededf3df06
13 changed files with 332 additions and 103 deletions

View File

@ -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");

View File

@ -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");

View File

@ -37,6 +37,10 @@ model Artwork {
published Boolean @default(false) published Boolean @default(false)
setAsHeader Boolean @default(false) setAsHeader Boolean @default(false)
colorStatus String @default("PENDING") // PENDING | PROCESSING | READY | FAILED
colorError String?
colorsGeneratedAt DateTime?
fileId String @unique fileId String @unique
file FileData @relation(fields: [fileId], references: [id]) file FileData @relation(fields: [fileId], references: [id])
@ -50,6 +54,10 @@ model Artwork {
colors ArtworkColor[] colors ArtworkColor[]
tags ArtTag[] tags ArtTag[]
variants FileVariant[] variants FileVariant[]
@@index([colorStatus])
@@index([published, sortKey, id])
@@index([year, published, sortKey, id])
} }
model Album { model Album {

View File

@ -1,8 +1,8 @@
"use server" "use server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { VibrantSwatch } from "@/types/VibrantSwatch"; import { VibrantSwatch } from "@/types/VibrantSwatch";
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3"; import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3";
import { generateColorName, rgbToHex } from "@/utils/uploadHelper"; import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
import { converter, parse } from "culori"; import { converter, parse } from "culori";
import { Vibrant } from "node-vibrant/node"; import { Vibrant } from "node-vibrant/node";
@ -11,19 +11,14 @@ const toOklab = converter("oklab");
const A_MIN = -0.5, A_MAX = 0.5; const A_MIN = -0.5, A_MAX = 0.5;
const B_MIN = -0.5, B_MAX = 0.5; const B_MIN = -0.5, B_MAX = 0.5;
function clamp01(x: number) { function clamp01(x: number) { return Math.max(0, Math.min(1, x)); }
return Math.max(0, Math.min(1, x)); function norm(x: number, lo: number, hi: number) { return clamp01((x - lo) / (hi - lo)); }
}
function norm(x: number, lo: number, hi: number) {
return clamp01((x - lo) / (hi - lo));
}
function hilbertIndex15(x01: number, y01: number): number { function hilbertIndex15(x01: number, y01: number): number {
let x = Math.floor(clamp01(x01) * 32767); let x = Math.floor(clamp01(x01) * 32767);
let y = Math.floor(clamp01(y01) * 32767); let y = Math.floor(clamp01(y01) * 32767);
let index = 0; 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 rx = (x & s) ? 1 : 0;
const ry = (y & s) ? 1 : 0; const ry = (y & s) ? 1 : 0;
index += s * s * ((3 * rx) ^ ry); index += s * s * ((3 * rx) ^ ry);
@ -36,7 +31,6 @@ function hilbertIndex15(x01: number, y01: number): number {
} }
function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>) { function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>) {
// Tweak weights as you like. Biasing toward Vibrant keeps things “readable”.
const weights: Record<string, number> = { const weights: Record<string, number> = {
Vibrant: 0.7, Vibrant: 0.7,
Muted: 0.15, Muted: 0.15,
@ -46,7 +40,6 @@ function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>)
LightMuted: 0.01, LightMuted: 0.01,
}; };
// Ensure we have at least a vibrant color to anchor on
const fallbackHex = const fallbackHex =
hexByType["Vibrant"] || hexByType["Vibrant"] ||
hexByType["Muted"] || hexByType["Muted"] ||
@ -57,8 +50,7 @@ function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>)
let L = 0, A = 0, B = 0, W = 0; let L = 0, A = 0, B = 0, W = 0;
const entries = Object.entries(weights); for (const [type, w] of Object.entries(weights)) {
for (const [type, w] of entries) {
const hex = hexByType[type] ?? fallbackHex; const hex = hexByType[type] ?? fallbackHex;
if (!hex || w <= 0) continue; if (!hex || w <= 0) continue;
const c = toOklab(parse(hex)); const c = toOklab(parse(hex));
@ -66,84 +58,92 @@ function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>)
L += c.l * w; A += c.a * w; B += c.b * w; W += w; L += c.l * w; A += c.a * w; B += c.b * w; W += w;
} }
if (W === 0) { if (W === 0) return { l: 0.5, a: 0, b: 0 };
// Should be rare; default to mid-gray
return { l: 0.5, a: 0, b: 0 };
}
return { l: L / W, a: A / W, b: B / W }; 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); * Generates ArtworkColor rows + sortKey/okLab fields for an artwork.
const palette = await Vibrant.from(buffer).getPalette(); * Safe to call multiple times.
*/
const vibrantHexes = Object.entries(palette).map(([key, swatch]) => { export async function generateArtworkColorsForArtwork(artworkId: string) {
const castSwatch = swatch as VibrantSwatch | null; // mark processing (optional but recommended)
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<string, string | undefined> = 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)
await prisma.artwork.update({ await prisma.artwork.update({
where: { id: artworkId }, where: { id: artworkId },
data: { sortKey, okLabL: l, okLabA: a, okLabB: b }, data: { colorStatus: "PROCESSING", colorError: null },
}); });
return await prisma.artworkColor.findMany({ try {
where: { artworkId }, const artwork = await prisma.artwork.findUnique({
include: { color: true }, 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<string, string | undefined> = 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" };
}
} }

View File

@ -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<ArtworkColorStats> {
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,
};
}

View File

@ -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<ProcessColorsResult> {
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,
};
}

View File

@ -21,7 +21,7 @@ export async function createImagesBulk(formData: FormData): Promise<BulkResult[]
continue; continue;
} }
const artwork = await createImageFromFile(f); const artwork = await createImageFromFile(f, { colorMode: "defer" });
if (!artwork) { if (!artwork) {
results.push({ ok: false, name: f.name, error: "Upload failed" }); results.push({ ok: false, name: f.name, error: "Upload failed" });
continue; continue;

View File

@ -7,7 +7,7 @@ import { createImageFromFile } from "./createImageFromFile";
export async function createImage(values: z.infer<typeof fileUploadSchema>) { export async function createImage(values: z.infer<typeof fileUploadSchema>) {
const imageFile = values.file[0]; const imageFile = values.file[0];
return createImageFromFile(imageFile); return createImageFromFile(imageFile, { colorMode: "inline" });
} }
/* /*

View File

@ -6,8 +6,9 @@ import { PutObjectCommand } from "@aws-sdk/client-s3";
import "dotenv/config"; import "dotenv/config";
import sharp from "sharp"; import sharp from "sharp";
import { v4 as uuidv4 } from "uuid"; 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)) { if (!(imageFile instanceof File)) {
console.log("No image or invalid type"); console.log("No image or invalid type");
return null; return null;
@ -126,6 +127,7 @@ export async function createImageFromFile(imageFile: File, opts?: { originalName
slug: artworkSlug, slug: artworkSlug,
creationDate: lastModified, creationDate: lastModified,
fileId: fileRecord.id, 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; return artworkRecord;
} }

View File

@ -1,3 +1,4 @@
import { ArtworkColorProcessor } from "@/components/artworks/ArtworkColorProcessor";
import { ArtworksTable } from "@/components/artworks/ArtworksTable"; import { ArtworksTable } from "@/components/artworks/ArtworksTable";
import { getArtworksPage } from "@/lib/queryArtworks"; import { getArtworksPage } from "@/lib/queryArtworks";
@ -56,6 +57,8 @@ export default async function ArtworksPage({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold">Artworks</h1> <h1 className="text-2xl font-bold">Artworks</h1>
{/* <ProcessArtworkColorsButton /> */}
<ArtworkColorProcessor />
<ArtworksTable /> <ArtworksTable />
</div> </div>
// <div> // <div>

View File

@ -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<Awaited<ReturnType<typeof getArtworkColorStats>> | null>(null);
const [loading, setLoading] = React.useState(false);
const [msg, setMsg] = React.useState<string | null>(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 (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<Button onClick={run} disabled={loading || done}>
{done ? "All colors processed" : loading ? "Processing…" : "Process pending colors"}
</Button>
{stats && (
<span className="text-sm text-muted-foreground">
Ready {stats.ready}/{stats.total}
{stats.failed > 0 && ` · Failed ${stats.failed}`}
</span>
)}
</div>
{msg && <p className="text-sm text-muted-foreground">{msg}</p>}
</div>
);
}

View File

@ -25,6 +25,17 @@ const artworkItems = [
} }
] ]
const commissionItems = [
{
title: "Commissions",
href: "/commissions",
},
{
title: "Types",
href: "/commissions/types",
}
]
// const portfolioItems = [ // const portfolioItems = [
// { // {
// title: "Images", // title: "Images",
@ -104,9 +115,22 @@ export default function TopNav() {
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem> <NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}> <NavigationMenuTrigger>Commissions</NavigationMenuTrigger>
<Link href="/commissions">Commissions</Link> <NavigationMenuContent>
</NavigationMenuLink> <ul className="grid w-50 gap-4">
{commissionItems.map((item) => (
<li key={item.title}>
<NavigationMenuLink asChild>
<Link href={item.href}>
<div className="text-sm leading-none font-medium">{item.title}</div>
<p className="text-muted-foreground line-clamp-2 text-sm leading-snug">
</p>
</Link>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem>
{/* <NavigationMenuItem> {/* <NavigationMenuItem>

View File

@ -1,23 +1,18 @@
import { s3 } from "@/lib/s3"; import { s3 } from "@/lib/s3";
import { GetObjectCommand } from "@aws-sdk/client-s3"; import { GetObjectCommand } from "@aws-sdk/client-s3";
import "dotenv/config";
import { Readable } from "stream"; import { Readable } from "stream";
export async function getImageBufferFromS3(fileKey: string, fileType?: string): Promise<Buffer> { export async function getImageBufferFromS3Key(s3Key: string): Promise<Buffer> {
// const type = fileType ? fileType.split("/")[1] : "webp";
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`, Bucket: process.env.BUCKET_NAME!,
Key: `original/${fileKey}.${fileType}`, Key: s3Key,
}); });
const response = await s3.send(command); const response = await s3.send(command);
const stream = response.Body as Readable; const stream = response.Body as Readable;
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];
for await (const chunk of stream) { for await (const chunk of stream) chunks.push(chunk);
chunks.push(chunk);
}
return Buffer.concat(chunks); return Buffer.concat(chunks);
} }