Procedural image generation using API endpoint

Notice the images you see next to the articles on my website and even in this article? They are created procedurally by simple logic and in this article I’ll show how to create an API endpoint that creates images as a scalable vector graphics.

Preparations

In this tutorial I will be using Astro.js but this code hardly uses any of the Astro API and can be replicated on almost any framework. Our goal is to create images infinitely that are bound with a variable aspect ratio, have a gradient background and a centered icon that can be replicated using a seed.

Given our needs we need to create 2 functions, one that uses the linear congurential generator algorithm for generating random numbers with set seed and the latter that handles the GET request:

import type { APIRoute } from "astro";

/**
 * Generates a seeded random number generator function
 * @param seed - Initial seed value
 * @returns A function that returns a pseudo-random number between 0 and 1
 */
function seededRandom(seed: number) {
  let value = (seed * 9301 + 49297) % 233280;
  return function () {
    value = (value * 9301 + 49297) % 233280;
    return value / 233280;
  };
}

/**
 * Generates an SVG with a randomized background gradient and figures
 * @param request - The incoming HTTP request
 * @returns An SVG image response with randomized content based on query parameters
 */
export const GET: APIRoute = ({ request }) => {
  return new Response("", {
    headers: {
      "Content-Type": "image/svg+xml",
      "Cache-Control": "no-cache",
    },
  });
}

Defining the vector output

I used Figma to create sample icons that I will try to generate later:

All the icons follow the same pattern:

We may now create the base for our icons, which consist of generating 2 background colors, gradient direction and choosing an icon.

/**
 * Generates an SVG with a randomized background gradient and figures
 * @param request - The incoming HTTP request
 * @returns An SVG image response with randomized content based on query parameters
 */
export const GET: APIRoute = ({ request }) => {
  const params = new URL(request.url).searchParams;

  const seed = parseInt(
    params.get("seed") || (Math.random() * 10000).toString(),
    10
  );
  const width = parseInt(params.get("w") || "512", 10);
  const height = parseInt(params.get("h") || "512", 10);
  const figureCount = parseInt(params.get("f") || "1", 10);

  const random = seededRandom(seed);

  const primaryHue = Math.floor(random() * 360);
  const backgroundFrom = `hsl(${primaryHue}, 100%, 70%)`;

  // secondary hue can either be the opposite or nearby
  const secondaryHue = primaryHue + (60 % 360);
  const backgroundTo = `hsl(${secondaryHue}, 50%, 60%)`;

  const y1 = random() > 0.5 ? "-50%" : "150%";
  const x1 = y1 === "-50%" ? "150%" : "50%";

  const selectedFigures = Array.from({ length: figureCount }, () => figures[Math.floor(random() * figures.length)]);

  ...

This code snipped get’s given parameters from search parameters and generates all the options provided before.

Creating the vector

From the previous Figma project I downloaded all glyphs visible inside the icons with some additional and imported them into a separate file figures.ts for the sake of readibility:

export const figures = [
  [
    `<rect x="255.184" y="30.72" width="317.44" height="317.44" transform="rotate(45 255.184 30.72)" opacity="0.8" />`,
  ],
  [
    `<circle cx="256" cy="247.603" r="104.96" opacity="0.7" />`,
    `<circle cx="360.96" cy="151.04" r="104.96" opacity="0.7" />`,
    `<circle cx="360.96" cy="360.96" r="104.96" opacity="0.7" />`,
    `<circle cx="151.04" cy="360.96" r="104.96" opacity="0.7" />`,
    `<circle cx="151.04" cy="151.04" r="104.96" opacity="0.7" />`,
  ],
  [
    `<path d="M329.984 128L480.742 215.04V389.12L329.984 476.16L179.226 389.12V215.04L329.984 128Z" opacity="0.5"/>`,
    `<path d="M179.2 40.96L329.958 128V302.08L179.2 389.12L28.4423 302.08V128L179.2 40.96Z" opacity="0.5"/>` 
  ],
  [
    `<path d="M256.32 285.64L415.946 562.12H96.6942L256.32 285.64Z" opacity="0.5" />`,
    `<path d="M256.32 101.32L415.946 377.8H96.6942L256.32 101.32Z" opacity="1" />`,
    `<path d="M256.32 -83L415.946 193.48H96.6942L256.32 -83Z" opacity="0.5" />`
  ]
];

Finally we’re able to create the final SVG output which consists of the linear gradient and mapped icons with appropriate translation by index so they are centered on the final image regardless of given dimensions.

  const svgContent = `
    <svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
      <defs>
        <linearGradient id="gradient" x1="${x1}" y1="${y1}" x2="-50%" y2="-50%">
          <stop offset="0%" style="stop-color:${backgroundFrom};stop-opacity:1" />
          <stop offset="100%" style="stop-color:${backgroundTo};stop-opacity:1" />
        </linearGradient>
      </defs>
      <rect width="${width}" height="${height}" fill="url(#gradient)" />

      ${selectedFigures.map((elem, index) => `
        <g style="mix-blend-mode:overlay" fill="#ccc" transform="translate(${(index % figureCount - ((figureCount - 1) / 2)) * (512 * 1.4)})">
          ${elem}
        </g>
      `).join('')}
      <style>
        g {
          translate: calc(50% - 256px) calc(50% - 256px); 
        }
      </style>
    </svg>
  `;

The final result is not very randomized as it only has 5 total icons but when more variety added it will result a better effect.

i’ll write the rest it’s just late rn