Add portfolio thingies
This commit is contained in:
@ -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<string, string | undefined>) {
|
||||
// Tweak weights as you like. Biasing toward Vibrant keeps things “readable”.
|
||||
const weights: Record<string, number> = {
|
||||
Vibrant: 0.7,
|
||||
Muted: 0.15,
|
||||
@ -46,7 +40,6 @@ function centroidFromPaletteHexes(hexByType: Record<string, string | undefined>)
|
||||
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<string, string | undefined>)
|
||||
|
||||
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<string, string | undefined>)
|
||||
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<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)
|
||||
/**
|
||||
* 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 },
|
||||
});
|
||||
}
|
||||
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<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" };
|
||||
}
|
||||
}
|
||||
|
||||
39
src/actions/colors/getArtworkColorStats.ts
Normal file
39
src/actions/colors/getArtworkColorStats.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
70
src/actions/colors/processPendingArtworkColors.ts
Normal file
70
src/actions/colors/processPendingArtworkColors.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -21,7 +21,7 @@ export async function createImagesBulk(formData: FormData): Promise<BulkResult[]
|
||||
continue;
|
||||
}
|
||||
|
||||
const artwork = await createImageFromFile(f);
|
||||
const artwork = await createImageFromFile(f, { colorMode: "defer" });
|
||||
if (!artwork) {
|
||||
results.push({ ok: false, name: f.name, error: "Upload failed" });
|
||||
continue;
|
||||
|
||||
@ -7,7 +7,7 @@ import { createImageFromFile } from "./createImageFromFile";
|
||||
|
||||
export async function createImage(values: z.infer<typeof fileUploadSchema>) {
|
||||
const imageFile = values.file[0];
|
||||
return createImageFromFile(imageFile);
|
||||
return createImageFromFile(imageFile, { colorMode: "inline" });
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Artworks</h1>
|
||||
{/* <ProcessArtworkColorsButton /> */}
|
||||
<ArtworkColorProcessor />
|
||||
<ArtworksTable />
|
||||
</div>
|
||||
// <div>
|
||||
|
||||
65
src/components/artworks/ArtworkColorProcessor.tsx
Normal file
65
src/components/artworks/ArtworkColorProcessor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
|
||||
<Link href="/commissions">Commissions</Link>
|
||||
</NavigationMenuLink>
|
||||
<NavigationMenuTrigger>Commissions</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<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>
|
||||
|
||||
@ -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<Buffer> {
|
||||
// const type = fileType ? fileType.split("/")[1] : "webp";
|
||||
|
||||
export async function getImageBufferFromS3Key(s3Key: string): Promise<Buffer> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user