Changes to image sort
This commit is contained in:
		@ -4,4 +4,12 @@ const nextConfig: NextConfig = {
 | 
				
			|||||||
  /* config options here */
 | 
					  /* config options here */
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = {
 | 
				
			||||||
 | 
					  experimental: {
 | 
				
			||||||
 | 
					    serverActions: {
 | 
				
			||||||
 | 
					      bodySizeLimit: '5mb',
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default nextConfig;
 | 
					export default nextConfig;
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4138
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4138
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										21
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								package.json
									
									
									
									
									
								
							@ -43,17 +43,19 @@
 | 
				
			|||||||
    "class-variance-authority": "^0.7.1",
 | 
					    "class-variance-authority": "^0.7.1",
 | 
				
			||||||
    "clsx": "^2.1.1",
 | 
					    "clsx": "^2.1.1",
 | 
				
			||||||
    "cmdk": "^1.1.1",
 | 
					    "cmdk": "^1.1.1",
 | 
				
			||||||
 | 
					    "culori": "^4.0.2",
 | 
				
			||||||
    "date-fns": "^4.1.0",
 | 
					    "date-fns": "^4.1.0",
 | 
				
			||||||
    "lowlight": "^3.3.0",
 | 
					    "lowlight": "^3.3.0",
 | 
				
			||||||
    "lucide-react": "^0.525.0",
 | 
					    "lucide-react": "^0.525.0",
 | 
				
			||||||
    "next": "15.4.2",
 | 
					    "next": "16.0.0",
 | 
				
			||||||
    "next-auth": "^4.24.11",
 | 
					    "next-auth": "^5.0.0-beta.30",
 | 
				
			||||||
    "next-themes": "^0.4.6",
 | 
					    "next-themes": "^0.4.6",
 | 
				
			||||||
    "node-vibrant": "^4.0.3",
 | 
					    "node-vibrant": "^4.0.3",
 | 
				
			||||||
 | 
					    "oklab": "^0.0.2-alpha.1",
 | 
				
			||||||
    "platejs": "^49.1.5",
 | 
					    "platejs": "^49.1.5",
 | 
				
			||||||
    "react": "19.1.0",
 | 
					    "react": "19.2.0",
 | 
				
			||||||
    "react-day-picker": "^9.8.0",
 | 
					    "react-day-picker": "^9.8.0",
 | 
				
			||||||
    "react-dom": "19.1.0",
 | 
					    "react-dom": "19.2.0",
 | 
				
			||||||
    "react-hook-form": "^7.60.0",
 | 
					    "react-hook-form": "^7.60.0",
 | 
				
			||||||
    "remark-gfm": "^4.0.1",
 | 
					    "remark-gfm": "^4.0.1",
 | 
				
			||||||
    "remark-math": "^6.0.0",
 | 
					    "remark-math": "^6.0.0",
 | 
				
			||||||
@ -67,16 +69,21 @@
 | 
				
			|||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@eslint/eslintrc": "^3",
 | 
					    "@eslint/eslintrc": "^3",
 | 
				
			||||||
    "@tailwindcss/postcss": "^4",
 | 
					    "@tailwindcss/postcss": "^4",
 | 
				
			||||||
 | 
					    "@types/culori": "^4.0.1",
 | 
				
			||||||
    "@types/node": "^20",
 | 
					    "@types/node": "^20",
 | 
				
			||||||
    "@types/react": "^19",
 | 
					    "@types/react": "19.2.2",
 | 
				
			||||||
    "@types/react-dom": "^19",
 | 
					    "@types/react-dom": "19.2.2",
 | 
				
			||||||
    "eslint": "^9",
 | 
					    "eslint": "^9",
 | 
				
			||||||
    "eslint-config-next": "15.4.2",
 | 
					    "eslint-config-next": "16.0.0",
 | 
				
			||||||
    "eslint-config-prettier": "^10.1.8",
 | 
					    "eslint-config-prettier": "^10.1.8",
 | 
				
			||||||
    "eslint-plugin-prettier": "^5.5.3",
 | 
					    "eslint-plugin-prettier": "^5.5.3",
 | 
				
			||||||
    "prisma": "^6.12.0",
 | 
					    "prisma": "^6.12.0",
 | 
				
			||||||
    "tailwindcss": "^4",
 | 
					    "tailwindcss": "^4",
 | 
				
			||||||
    "tw-animate-css": "^1.3.5",
 | 
					    "tw-animate-css": "^1.3.5",
 | 
				
			||||||
    "typescript": "^5"
 | 
					    "typescript": "^5"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "overrides": {
 | 
				
			||||||
 | 
					    "@types/react": "19.2.2",
 | 
				
			||||||
 | 
					    "@types/react-dom": "19.2.2"
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					-- AlterTable
 | 
				
			||||||
 | 
					ALTER TABLE "PortfolioImage" ADD COLUMN     "okLabA" DOUBLE PRECISION,
 | 
				
			||||||
 | 
					ADD COLUMN     "okLabB" DOUBLE PRECISION,
 | 
				
			||||||
 | 
					ADD COLUMN     "okLabL" DOUBLE PRECISION,
 | 
				
			||||||
 | 
					ADD COLUMN     "sortKey" INTEGER;
 | 
				
			||||||
@ -34,7 +34,11 @@ model PortfolioImage {
 | 
				
			|||||||
  altText      String?
 | 
					  altText      String?
 | 
				
			||||||
  description  String?
 | 
					  description  String?
 | 
				
			||||||
  month        Int?
 | 
					  month        Int?
 | 
				
			||||||
 | 
					  sortKey      Int?
 | 
				
			||||||
  year         Int?
 | 
					  year         Int?
 | 
				
			||||||
 | 
					  okLabL       Float?
 | 
				
			||||||
 | 
					  okLabA       Float?
 | 
				
			||||||
 | 
					  okLabB       Float?
 | 
				
			||||||
  creationDate DateTime?
 | 
					  creationDate DateTime?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  albumId String?
 | 
					  albumId String?
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										159
									
								
								prisma/scripts/backfillSortKeys.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								prisma/scripts/backfillSortKeys.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,159 @@
 | 
				
			|||||||
 | 
					/* 
 | 
				
			||||||
 | 
					  Backfill sortKey for images using a Hilbert (order 15) index on OKLab (a,b).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  - Safe to resume: processes in ID order, skips rows with existing sortKey.
 | 
				
			||||||
 | 
					  - Batches writes via Prisma transaction.
 | 
				
			||||||
 | 
					  - Uses existing swatches from imageColor/color; if none, falls back to mid-gray.
 | 
				
			||||||
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { PrismaClient } from "@prisma/client";
 | 
				
			||||||
 | 
					import { converter, parse } from "culori";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const prisma = new PrismaClient();
 | 
				
			||||||
 | 
					const toOklab = converter("oklab");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ---------- Config ----------
 | 
				
			||||||
 | 
					const BATCH_SIZE = 300;          // tune for your DB
 | 
				
			||||||
 | 
					const LOG_EVERY = 5;             // batches
 | 
				
			||||||
 | 
					// Fixed OKLab box for incremental safety (covers real gamut with cushion)
 | 
				
			||||||
 | 
					const A_MIN = -0.5, A_MAX = 0.5;
 | 
				
			||||||
 | 
					const B_MIN = -0.5, B_MAX = 0.5;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Weight Node-Vibrant swatch types toward a perceptual centroid.
 | 
				
			||||||
 | 
					// Adjust these if your dataset prefers different emphasis.
 | 
				
			||||||
 | 
					const TYPE_WEIGHTS: Record<string, number> = {
 | 
				
			||||||
 | 
					  Vibrant: 0.7,
 | 
				
			||||||
 | 
					  Muted: 0.15,
 | 
				
			||||||
 | 
					  DarkVibrant: 0.07,
 | 
				
			||||||
 | 
					  DarkMuted: 0.05,
 | 
				
			||||||
 | 
					  LightVibrant: 0.02,
 | 
				
			||||||
 | 
					  LightMuted: 0.01,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// ---------- Math helpers ----------
 | 
				
			||||||
 | 
					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)); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Hilbert curve index for a 32768 x 32768 grid (order = 15) → fits INT4
 | 
				
			||||||
 | 
					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) {
 | 
				
			||||||
 | 
					    const rx = (x & s) ? 1 : 0;
 | 
				
			||||||
 | 
					    const ry = (y & s) ? 1 : 0;
 | 
				
			||||||
 | 
					    index += s * s * ((3 * rx) ^ ry);
 | 
				
			||||||
 | 
					    if (ry === 0) {
 | 
				
			||||||
 | 
					      if (rx === 1) { x = 32767 - x; y = 32767 - y; }
 | 
				
			||||||
 | 
					      const t = x; x = y; y = t;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  // stays within 0..(2^30-1)
 | 
				
			||||||
 | 
					  return index | 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SwatchRow = { imageId: string; type: string; color: { hex: string } };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function centroidFromSwatches(swatches: SwatchRow[]) {
 | 
				
			||||||
 | 
					  // Build hexByType and choose a fallback if any missing
 | 
				
			||||||
 | 
					  const byType: Record<string, string | undefined> = {};
 | 
				
			||||||
 | 
					  for (const s of swatches) {
 | 
				
			||||||
 | 
					    // Ensure we only keep one hex per type (first wins)
 | 
				
			||||||
 | 
					    if (!byType[s.type]) byType[s.type] = s.color?.hex;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  const fallbackHex =
 | 
				
			||||||
 | 
					    byType["Vibrant"] ||
 | 
				
			||||||
 | 
					    byType["Muted"] ||
 | 
				
			||||||
 | 
					    byType["DarkVibrant"] ||
 | 
				
			||||||
 | 
					    byType["DarkMuted"] ||
 | 
				
			||||||
 | 
					    byType["LightVibrant"] ||
 | 
				
			||||||
 | 
					    byType["LightMuted"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!fallbackHex) {
 | 
				
			||||||
 | 
					    // No swatches at all: mid-gray
 | 
				
			||||||
 | 
					    return { l: 0.5, a: 0, b: 0 };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let L = 0, A = 0, B = 0, W = 0;
 | 
				
			||||||
 | 
					  for (const [type, w] of Object.entries(TYPE_WEIGHTS)) {
 | 
				
			||||||
 | 
					    const hex = byType[type] ?? fallbackHex;
 | 
				
			||||||
 | 
					    if (!hex || w <= 0) continue;
 | 
				
			||||||
 | 
					    const c = toOklab(parse(hex));
 | 
				
			||||||
 | 
					    if (!c) continue;
 | 
				
			||||||
 | 
					    L += c.l * w; A += c.a * w; B += c.b * w; W += w;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  if (W === 0) return { l: 0.5, a: 0, b: 0 };
 | 
				
			||||||
 | 
					  return { l: L / W, a: A / W, b: B / W };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function fetchBatch(afterId?: string) {
 | 
				
			||||||
 | 
					  // Only rows that still need a key; stable ID ordering so we can resume
 | 
				
			||||||
 | 
					  return prisma.portfolioImage.findMany({
 | 
				
			||||||
 | 
					    where: { sortKey: null },
 | 
				
			||||||
 | 
					    orderBy: { id: "asc" },
 | 
				
			||||||
 | 
					    ...(afterId ? { cursor: { id: afterId }, skip: 1 } : {}),
 | 
				
			||||||
 | 
					    take: BATCH_SIZE,
 | 
				
			||||||
 | 
					    select: { id: true },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function backfill() {
 | 
				
			||||||
 | 
					  let processed = 0;
 | 
				
			||||||
 | 
					  let batches = 0;
 | 
				
			||||||
 | 
					  let cursor: string | undefined = undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  while (true) {
 | 
				
			||||||
 | 
					    const images = await fetchBatch(cursor);
 | 
				
			||||||
 | 
					    if (images.length === 0) break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const ids = images.map((i: { id: string }) => i.id);
 | 
				
			||||||
 | 
					    cursor = ids[ids.length - 1];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Pull all swatches for this batch in one go
 | 
				
			||||||
 | 
					    const swatches = await prisma.imageColor.findMany({
 | 
				
			||||||
 | 
					      where: { imageId: { in: ids } },
 | 
				
			||||||
 | 
					      select: { imageId: true, type: true, color: { select: { hex: true } } },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Group swatches by imageId
 | 
				
			||||||
 | 
					    const byImage = new Map<string, SwatchRow[]>();
 | 
				
			||||||
 | 
					    for (const s of swatches) {
 | 
				
			||||||
 | 
					      const arr = byImage.get(s.imageId) ?? [];
 | 
				
			||||||
 | 
					      arr.push(s as SwatchRow);
 | 
				
			||||||
 | 
					      byImage.set(s.imageId, arr);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Prepare updates
 | 
				
			||||||
 | 
					    const updates = images.map(({ id }: { id: string }) => {
 | 
				
			||||||
 | 
					      const rows = byImage.get(id) ?? [];
 | 
				
			||||||
 | 
					      const { a, b, l } = centroidFromSwatches(rows);
 | 
				
			||||||
 | 
					      const ax = norm(a, A_MIN, A_MAX);
 | 
				
			||||||
 | 
					      const bx = norm(b, B_MIN, B_MAX);
 | 
				
			||||||
 | 
					      const sortKey = hilbertIndex15(ax, bx);
 | 
				
			||||||
 | 
					      // Optional: also persist okLab* for debugging/analytics
 | 
				
			||||||
 | 
					      return prisma.portfolioImage.update({
 | 
				
			||||||
 | 
					        where: { id },
 | 
				
			||||||
 | 
					        data: { sortKey, okLabL: l, okLabA: a, okLabB: b },
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await prisma.$transaction(updates);
 | 
				
			||||||
 | 
					    processed += images.length;
 | 
				
			||||||
 | 
					    batches += 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (batches % LOG_EVERY === 0) {
 | 
				
			||||||
 | 
					      console.log(`[backfill] processed ${processed} images… last id: ${cursor}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  console.log(`[backfill] done. processed ${processed} images total.`);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					backfill()
 | 
				
			||||||
 | 
					  .catch((e) => {
 | 
				
			||||||
 | 
					    console.error("[backfill] error:", e);
 | 
				
			||||||
 | 
					    process.exit(1);
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  .finally(async () => {
 | 
				
			||||||
 | 
					    await prisma.$disconnect();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
							
								
								
									
										52
									
								
								src/actions/portfolio/images/generateColorSortKey.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/actions/portfolio/images/generateColorSortKey.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,52 @@
 | 
				
			|||||||
 | 
					"use server";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import prisma from "@/lib/prisma";
 | 
				
			||||||
 | 
					import { centroidFromSwatches, sortKeyFromCentroid } from "@/utils/colorSortKey";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const BATCH_SIZE = 300;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function getRemainingSortKeyBackfill() {
 | 
				
			||||||
 | 
					  return prisma.portfolioImage.count({ where: { sortKey: null } });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function backfillSortKeysBatch() {
 | 
				
			||||||
 | 
					 // Grab a stable batch (ASC id so it’s deterministic)
 | 
				
			||||||
 | 
					  const images = await prisma.portfolioImage.findMany({
 | 
				
			||||||
 | 
					    where: { sortKey: null },
 | 
				
			||||||
 | 
					    orderBy: { id: "asc" },
 | 
				
			||||||
 | 
					    take: BATCH_SIZE,
 | 
				
			||||||
 | 
					    select: { id: true },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const ids = images.map((i: { id: string }) => i.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Get all swatches for those images
 | 
				
			||||||
 | 
					  const swatches = await prisma.imageColor.findMany({
 | 
				
			||||||
 | 
					    where: { imageId: { in: ids } },
 | 
				
			||||||
 | 
					    select: { imageId: true, type: true, color: { select: { hex: true } } },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Group into hexByType per image
 | 
				
			||||||
 | 
					  const byImage = new Map<string, Record<string, string | undefined>>();
 | 
				
			||||||
 | 
					  for (const s of swatches) {
 | 
				
			||||||
 | 
					    const map = byImage.get(s.imageId) ?? {};
 | 
				
			||||||
 | 
					    if (!map[s.type]) map[s.type] = s.color?.hex ?? undefined;
 | 
				
			||||||
 | 
					    byImage.set(s.imageId, map);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Prepare updates
 | 
				
			||||||
 | 
					  const updates = images.map(({ id }: { id: string }) => {
 | 
				
			||||||
 | 
					    const hexByType = byImage.get(id) ?? {};
 | 
				
			||||||
 | 
					    const { l, a, b } = centroidFromSwatches(hexByType);
 | 
				
			||||||
 | 
					    const sortKey = sortKeyFromCentroid(a, b);
 | 
				
			||||||
 | 
					    return prisma.portfolioImage.update({
 | 
				
			||||||
 | 
					      where: { id },
 | 
				
			||||||
 | 
					      data: { sortKey, okLabL: l, okLabA: a, okLabB: b },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  await prisma.$transaction(updates);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const remaining = await getRemainingSortKeyBackfill();
 | 
				
			||||||
 | 
					  return { processed: ids.length, remaining };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -4,8 +4,75 @@ import prisma from "@/lib/prisma";
 | 
				
			|||||||
import { VibrantSwatch } from "@/types/VibrantSwatch";
 | 
					import { VibrantSwatch } from "@/types/VibrantSwatch";
 | 
				
			||||||
import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
 | 
					import { getImageBufferFromS3 } from "@/utils/getImageBufferFromS3";
 | 
				
			||||||
import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
 | 
					import { generateColorName, rgbToHex } from "@/utils/uploadHelper";
 | 
				
			||||||
 | 
					import { converter, parse } from "culori";
 | 
				
			||||||
import { Vibrant } from "node-vibrant/node";
 | 
					import { Vibrant } from "node-vibrant/node";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 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
 | 
				
			||||||
 | 
					    const rx = (x & s) ? 1 : 0;
 | 
				
			||||||
 | 
					    const ry = (y & s) ? 1 : 0;
 | 
				
			||||||
 | 
					    index += s * s * ((3 * rx) ^ ry);
 | 
				
			||||||
 | 
					    if (ry === 0) {
 | 
				
			||||||
 | 
					      if (rx === 1) { x = 32767 - x; y = 32767 - y; }
 | 
				
			||||||
 | 
					      const t = x; x = y; y = t;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return index >>> 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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,
 | 
				
			||||||
 | 
					    DarkVibrant: 0.07,
 | 
				
			||||||
 | 
					    DarkMuted: 0.05,
 | 
				
			||||||
 | 
					    LightVibrant: 0.02,
 | 
				
			||||||
 | 
					    LightMuted: 0.01,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Ensure we have at least a vibrant color to anchor on
 | 
				
			||||||
 | 
					  const fallbackHex =
 | 
				
			||||||
 | 
					    hexByType["Vibrant"] ||
 | 
				
			||||||
 | 
					    hexByType["Muted"] ||
 | 
				
			||||||
 | 
					    hexByType["DarkVibrant"] ||
 | 
				
			||||||
 | 
					    hexByType["DarkMuted"] ||
 | 
				
			||||||
 | 
					    hexByType["LightVibrant"] ||
 | 
				
			||||||
 | 
					    hexByType["LightMuted"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let L = 0, A = 0, B = 0, W = 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const entries = Object.entries(weights);
 | 
				
			||||||
 | 
					  for (const [type, w] of entries) {
 | 
				
			||||||
 | 
					    const hex = hexByType[type] ?? fallbackHex;
 | 
				
			||||||
 | 
					    if (!hex || w <= 0) continue;
 | 
				
			||||||
 | 
					    const c = toOklab(parse(hex));
 | 
				
			||||||
 | 
					    if (!c) continue;
 | 
				
			||||||
 | 
					    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 };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return { l: L / W, a: A / W, b: B / W };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function generateImageColors(imageId: string, fileKey: string, fileType?: string) {
 | 
					export async function generateImageColors(imageId: string, fileKey: string, fileType?: string) {
 | 
				
			||||||
  const buffer = await getImageBufferFromS3(fileKey, fileType);
 | 
					  const buffer = await getImageBufferFromS3(fileKey, fileType);
 | 
				
			||||||
  const palette = await Vibrant.from(buffer).getPalette();
 | 
					  const palette = await Vibrant.from(buffer).getPalette();
 | 
				
			||||||
@ -57,7 +124,23 @@ export async function generateImageColors(imageId: string, fileKey: string, file
 | 
				
			|||||||
        colorId: color.id,
 | 
					        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.portfolioImage.update({
 | 
				
			||||||
 | 
					    where: { id: imageId },
 | 
				
			||||||
 | 
					    data: { sortKey, okLabL: l, okLabA: a, okLabB: b },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return await prisma.imageColor.findMany({
 | 
					  return await prisma.imageColor.findMany({
 | 
				
			||||||
    where: { imageId },
 | 
					    where: { imageId },
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										76
									
								
								src/components/portfolio/images/BackfillButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/components/portfolio/images/BackfillButton.tsx
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
				
			|||||||
 | 
					"use client";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { backfillSortKeysBatch, getRemainingSortKeyBackfill } from "@/actions/portfolio/images/generateColorSortKey";
 | 
				
			||||||
 | 
					import { Button } from "@/components/ui/button"; // shadcn
 | 
				
			||||||
 | 
					import { Loader2, Palette } from "lucide-react";
 | 
				
			||||||
 | 
					import * as React from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Props = {
 | 
				
			||||||
 | 
					  className?: string;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function BackfillButton({ className }: Props) {
 | 
				
			||||||
 | 
					  const [remaining, setRemaining] = React.useState<number | null>(null);
 | 
				
			||||||
 | 
					  const [isPending, startTransition] = React.useTransition();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // initial load (client-only)
 | 
				
			||||||
 | 
					  React.useEffect(() => {
 | 
				
			||||||
 | 
					    let mounted = true;
 | 
				
			||||||
 | 
					    (async () => {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const n = await getRemainingSortKeyBackfill();
 | 
				
			||||||
 | 
					        if (mounted) setRemaining(n);
 | 
				
			||||||
 | 
					      } catch (e) {
 | 
				
			||||||
 | 
					        console.error(e);
 | 
				
			||||||
 | 
					        if (mounted) setRemaining(0);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })();
 | 
				
			||||||
 | 
					    return () => { mounted = false; };
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const disabled = remaining === null || isPending || remaining === 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onClick = () => {
 | 
				
			||||||
 | 
					    startTransition(async () => {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const res = await backfillSortKeysBatch();
 | 
				
			||||||
 | 
					        setRemaining(res.remaining);
 | 
				
			||||||
 | 
					      } catch (err) {
 | 
				
			||||||
 | 
					        console.error(err);
 | 
				
			||||||
 | 
					        // swap for your toast if you like
 | 
				
			||||||
 | 
					        alert("Backfill failed. See console.");
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Button
 | 
				
			||||||
 | 
					      type="button"
 | 
				
			||||||
 | 
					      onClick={onClick}
 | 
				
			||||||
 | 
					      disabled={disabled}
 | 
				
			||||||
 | 
					      className={className}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      {isPending ? (
 | 
				
			||||||
 | 
					        <>
 | 
				
			||||||
 | 
					          <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
 | 
				
			||||||
 | 
					          Processing…
 | 
				
			||||||
 | 
					        </>
 | 
				
			||||||
 | 
					      ) : remaining === null ? (
 | 
				
			||||||
 | 
					        <>
 | 
				
			||||||
 | 
					          <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
 | 
				
			||||||
 | 
					          Loading…
 | 
				
			||||||
 | 
					        </>
 | 
				
			||||||
 | 
					      ) : remaining > 0 ? (
 | 
				
			||||||
 | 
					        <>
 | 
				
			||||||
 | 
					          <Palette className="mr-2 h-4 w-4" aria-hidden />
 | 
				
			||||||
 | 
					          Backfill color sort keys · {remaining} left
 | 
				
			||||||
 | 
					        </>
 | 
				
			||||||
 | 
					      ) : (
 | 
				
			||||||
 | 
					        <>
 | 
				
			||||||
 | 
					          <Palette className="mr-2 h-4 w-4" aria-hidden />
 | 
				
			||||||
 | 
					          All done
 | 
				
			||||||
 | 
					        </>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </Button>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -4,6 +4,7 @@ import { PortfolioAlbum, PortfolioType } from "@/generated/prisma";
 | 
				
			|||||||
import { SortAscIcon } from "lucide-react";
 | 
					import { SortAscIcon } from "lucide-react";
 | 
				
			||||||
import Link from "next/link";
 | 
					import Link from "next/link";
 | 
				
			||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
 | 
					import { usePathname, useRouter, useSearchParams } from "next/navigation";
 | 
				
			||||||
 | 
					import BackfillButton from "./BackfillButton";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type FilterBarProps = {
 | 
					type FilterBarProps = {
 | 
				
			||||||
  types: PortfolioType[];
 | 
					  types: PortfolioType[];
 | 
				
			||||||
@ -47,13 +48,19 @@ export default function FilterBar({
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  const sortHref = `${pathname}/sort?${params.toString()}`;
 | 
					  const sortHref = `${pathname}/sort?${params.toString()}`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const sortByColor = () => {
 | 
				
			||||||
 | 
					    params.set("sortBy", "color");
 | 
				
			||||||
 | 
					    router.push(`${pathname}?${params.toString()}`);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div>
 | 
					    <div>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <div className="flex justify-end">
 | 
					        <div className="flex justify-end gap-4">
 | 
				
			||||||
          <Link href={sortHref} className="flex gap-2 items-center cursor-pointer bg-secondary hover:bg-secondary/90 text-secondary-foreground px-4 py-2 rounded">
 | 
					          <Link href={sortHref} className="flex gap-2 items-center cursor-pointer bg-secondary hover:bg-secondary/90 text-secondary-foreground px-4 py-2 rounded">
 | 
				
			||||||
            <SortAscIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Sort images
 | 
					            <SortAscIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" /> Sort images
 | 
				
			||||||
          </Link>
 | 
					          </Link>
 | 
				
			||||||
 | 
					          <BackfillButton />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div className="flex gap-6 pb-6">
 | 
					      <div className="flex gap-6 pb-6">
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										64
									
								
								src/utils/colorSortKey.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/utils/colorSortKey.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,64 @@
 | 
				
			|||||||
 | 
					import { converter, parse } from "culori";
 | 
				
			||||||
 | 
					const toOklab = converter("oklab");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Fixed OKLab box so inserts/edits are incremental-safe
 | 
				
			||||||
 | 
					const A_MIN = -0.5, A_MAX = 0.5;
 | 
				
			||||||
 | 
					const B_MIN = -0.5, B_MAX = 0.5;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TYPE_WEIGHTS: Record<string, number> = {
 | 
				
			||||||
 | 
					  Vibrant: 0.7,
 | 
				
			||||||
 | 
					  Muted: 0.15,
 | 
				
			||||||
 | 
					  DarkVibrant: 0.07,
 | 
				
			||||||
 | 
					  DarkMuted: 0.05,
 | 
				
			||||||
 | 
					  LightVibrant: 0.02,
 | 
				
			||||||
 | 
					  LightMuted: 0.01,
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 - hi ? hi - lo : 1)); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Hilbert index for 32768 x 32768 grid (order 15) → fits INT4
 | 
				
			||||||
 | 
					export 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) {
 | 
				
			||||||
 | 
					    const rx = (x & s) ? 1 : 0;
 | 
				
			||||||
 | 
					    const ry = (y & s) ? 1 : 0;
 | 
				
			||||||
 | 
					    index += s * s * ((3 * rx) ^ ry);
 | 
				
			||||||
 | 
					    if (ry === 0) {
 | 
				
			||||||
 | 
					      if (rx === 1) { x = 32767 - x; y = 32767 - y; }
 | 
				
			||||||
 | 
					      const t = x; x = y; y = t;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return index | 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function centroidFromSwatches(hexByType: Record<string, string | undefined>) {
 | 
				
			||||||
 | 
					  const fallback =
 | 
				
			||||||
 | 
					    hexByType["Vibrant"] ??
 | 
				
			||||||
 | 
					    hexByType["Muted"] ??
 | 
				
			||||||
 | 
					    hexByType["DarkVibrant"] ??
 | 
				
			||||||
 | 
					    hexByType["DarkMuted"] ??
 | 
				
			||||||
 | 
					    hexByType["LightVibrant"] ??
 | 
				
			||||||
 | 
					    hexByType["LightMuted"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!fallback) return { l: 0.5, a: 0, b: 0 };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let L = 0, A = 0, B = 0, W = 0;
 | 
				
			||||||
 | 
					  for (const [type, w] of Object.entries(TYPE_WEIGHTS)) {
 | 
				
			||||||
 | 
					    const hex = hexByType[type] ?? fallback;
 | 
				
			||||||
 | 
					    if (!hex || w <= 0) continue;
 | 
				
			||||||
 | 
					    const c = toOklab(parse(hex));
 | 
				
			||||||
 | 
					    if (!c) continue;
 | 
				
			||||||
 | 
					    L += c.l * w; A += c.a * w; B += c.b * w; W += w;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  if (!W) return { l: 0.5, a: 0, b: 0 };
 | 
				
			||||||
 | 
					  return { l: L / W, a: A / W, b: B / W };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function sortKeyFromCentroid(a: number, b: number) {
 | 
				
			||||||
 | 
					  const ax = norm(a, A_MIN, A_MAX);
 | 
				
			||||||
 | 
					  const bx = norm(b, B_MIN, B_MAX);
 | 
				
			||||||
 | 
					  return hilbertIndex15(ax, bx);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,7 +1,11 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "compilerOptions": {
 | 
					  "compilerOptions": {
 | 
				
			||||||
    "target": "ES2017",
 | 
					    "target": "ES2017",
 | 
				
			||||||
    "lib": ["dom", "dom.iterable", "esnext"],
 | 
					    "lib": [
 | 
				
			||||||
 | 
					      "dom",
 | 
				
			||||||
 | 
					      "dom.iterable",
 | 
				
			||||||
 | 
					      "esnext"
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
    "allowJs": true,
 | 
					    "allowJs": true,
 | 
				
			||||||
    "skipLibCheck": true,
 | 
					    "skipLibCheck": true,
 | 
				
			||||||
    "strict": true,
 | 
					    "strict": true,
 | 
				
			||||||
@ -11,7 +15,7 @@
 | 
				
			|||||||
    "moduleResolution": "bundler",
 | 
					    "moduleResolution": "bundler",
 | 
				
			||||||
    "resolveJsonModule": true,
 | 
					    "resolveJsonModule": true,
 | 
				
			||||||
    "isolatedModules": true,
 | 
					    "isolatedModules": true,
 | 
				
			||||||
    "jsx": "preserve",
 | 
					    "jsx": "react-jsx",
 | 
				
			||||||
    "incremental": true,
 | 
					    "incremental": true,
 | 
				
			||||||
    "plugins": [
 | 
					    "plugins": [
 | 
				
			||||||
      {
 | 
					      {
 | 
				
			||||||
@ -19,9 +23,19 @@
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
    ],
 | 
					    ],
 | 
				
			||||||
    "paths": {
 | 
					    "paths": {
 | 
				
			||||||
      "@/*": ["./src/*"]
 | 
					      "@/*": [
 | 
				
			||||||
 | 
					        "./src/*"
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
 | 
					  "include": [
 | 
				
			||||||
  "exclude": ["node_modules"]
 | 
					    "next-env.d.ts",
 | 
				
			||||||
 | 
					    "**/*.ts",
 | 
				
			||||||
 | 
					    "**/*.tsx",
 | 
				
			||||||
 | 
					    ".next/types/**/*.ts",
 | 
				
			||||||
 | 
					    ".next/dev/types/**/*.ts"
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					  "exclude": [
 | 
				
			||||||
 | 
					    "node_modules"
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user