blogprojectshajspace
Back to blog

generating cover images from project names

·3 min read

Gravatar generates unique avatars from email hashes. Same email, same avatar, every time. I wanted the same thing for my portfolio: abstract art generated from project IDs that could serve as cover images and social previews.

The key constraint was determinism. Same input → same output. Forever. No database, no storage, just pure computation.

the problem with math.random

JavaScript's Math.random() can't be seeded. It produces different results every time. I needed a pseudorandom number generator that accepts a seed value and produces repeatable sequences.

The mulberry32 algorithm is fast and deterministic:

ts
export class SeededRandom {
  private seed: number;

  constructor(seed: number) {
    this.seed = seed;
  }

  next(): number {
    let t = (this.seed += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  }

  nextInt(min: number, max: number): number {
    return Math.floor(this.next() * (max - min)) + min;
  }

  pick<T>(array: T[]): T {
    return array[this.nextInt(0, array.length)];
  }
}

converting strings to seeds

Projects have string IDs like "my-cool-project". I needed a hash function to convert strings to numbers:

ts
export function stringToSeed(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = (hash << 5) - hash + char;
    hash = hash & hash;
  }
  return Math.abs(hash);
}

Same string → same hash → same seed → same art.

the generator

The art is soft, blurred, colorful blobs layered on top of each other:

ts
function generateAbstractArt(
  width: number,
  height: number,
  stringSeed: string,
  darkMode: boolean = false
): string {
  const rng = createSeededRandom(stringSeed);
  
  const colors = [
    darkMode ? "#ff62b8" : "#ff3ea5",
    darkMode ? "#818cf8" : "#6366f1",
    darkMode ? "#fbbf24" : "#f59e0b",
  ];
  
  const clouds: Cloud[] = [];
  const cloudCount = rng.nextInt(4, 9);
  
  for (let i = 0; i < cloudCount; i++) {
    clouds.push({
      type: "blob",
      x: rng.nextFloat(-width * 0.4, width * 0.7),
      y: rng.nextFloat(-height * 0.4, height * 0.7),
      width: rng.nextFloat(width * 0.35, width * 0.9),
      height: rng.nextFloat(height * 0.35, height * 0.9),
      color: rng.pick(colors),
      opacity: rng.nextFloat(0.12, 0.28),
      blur: rng.nextFloat(50, 100),
      rotation: rng.nextFloat(0, 360),
    });
  }
  
  return generateSVG(clouds, backgroundColor, width, height);
}

Because the RNG is seeded, the composition is identical for identical input.

the api endpoint

Exposed via Next.js API route with aggressive caching:

ts
export async function GET(request: NextRequest) {
  const params = request.nextUrl.searchParams;
  
  const width = parseInt(params.get("width") || "400");
  const height = parseInt(params.get("height") || "400");
  const stringSeed = params.get("string") || "default";
  const darkMode = params.get("dark") === "true";
  
  const svg = generateAbstractArt(width, height, stringSeed, darkMode);
  
  return new Response(svg, {
    headers: {
      "Content-Type": "image/svg+xml",
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  });
}

Generated once per unique input, cached for a year, served from edge forever.

usage

When a project doesn't have a custom image:

ts
function getImageUrl(project: Project, isDark: boolean): string {
  if (project.image) return project.image;
  
  return `/api/abstract-art?width=${width}&height=${height}&string=${encodeURIComponent(project.id)}&dark=${isDark}`;
}

Dark mode changes the color palette but keeps blob positions unchanged. Same project, consistent visual identity across themes.

why this works

Property Benefit
No database Zero storage costs
Deterministic Infinitely cacheable
SVG output Lightweight, scales perfectly
Pure JS No image processing libraries

I tried more complex generators with geometric shapes and gradients. The soft blobs looked best. Sometimes simpler is actually better.

Back to blog