Changes to image sort

This commit is contained in:
2025-10-29 09:35:46 +01:00
parent ef281ef70f
commit 8454541792
12 changed files with 2873 additions and 1772 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
} }
} }

View File

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

View File

@ -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?

View 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();
});

View 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 its 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 };
}

View File

@ -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();
@ -59,6 +126,22 @@ export async function generateImageColors(imageId: string, fileKey: string, file
}); });
} }
// 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 },
include: { color: true }, include: { color: true },

View 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>
);
}

View File

@ -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
View 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);
}

View File

@ -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"
]
} }