github pqt-graveyard/social-preview v3.0.0
Dynamically Generated Images

3 years ago

v3.0.0

Hey! It's been quite some time since I made some upgrades to this project so it was about time and there are some sweet things happening in this update.

Completely Dynamic Images

I've always loved the way that GitHub has used dots and squares in a seemingly random way to create the textures seen on their marketing pages.

It makes use of the fantastic library https://github.com/davidbau/seedrandom library by @davidbau and honestly couldn't have been done without such a great library to jump-start the way I wanted to approach this new idea I had. It needed to be random, yet consistent and that's exactly the problem this library solves.

I also get a lot more intimate with the https://github.com/oliver-moran/jimp library by @oliver-moran. Another brilliant creation that makes this entire project possible.

Features

  • Acknowledges your Repository Language Colors (and percentages)
  • Use your own GitHub Access Token (optional)
  • Dark mode
  • Squares or Circles
  • Customize the unique seeder ID (optional)

Examples

repo languages light dark
pqt/social-preview pqt-social-preview pqt-social-preview (1)
microsoft/CCF microsoft-CCF microsoft-CCF (1)
laravel/laravel laravel-laravel laravel-laravel (1)
tailwindlabs/tailwindcss tailwindlabs-tailwindcss tailwindlabs-tailwindcss (1)

Technicals (Long read ahead, grab that popcorn!)

Let's get into the meat and potatoes of how this works. Using NextJS API Routing I instantiate Octokit. Either using a user-provided GitHub Access Token or one I have sorted as an environment variable.

/**
 * Instantiate the GitHub API Client
 */
const octokit = new Octokit({
  auth: token || process.env.GITHUB_TOKEN,
});

Next, I fetch the repository that was passed into the endpoint (owner/repository). I also grab the languages used on the repository.

/**
 * Fetch the GitHub Repository
 */
const { data: repository } = await octokit.repos.get({
  owner,
  repo,
});

/**
 * Fetch the GitHub Repository Language Data
 */
const { data: languages } = await octokit.repos.listLanguages({
  owner,
  repo,
});

I decided to add an extra layer of personalization using the above-listed languages. The dots are colored relative to the language percentages found on the repository. In other words, if the project is 50% typescript, roughly half of the colored dots are going match the GitHub-assigned color for the TypeScript language. This accounts for all listed languages. I source this directly from the https://github.com/github/linguist repository (see the actual file we need here.).

/**
 * Fetch the GitHub language colors source-of-truth
 */
const { data: linguistInitial } = await octokit.repos.getContents({
  owner: 'github',
  repo: 'linguist',
  path: 'lib/linguist/languages.yml',
  mediaType: {
    format: 'base64',
  },
});

/**
 * Create a Buffer for the linguistInitial content
 * Parse the YML file so we can extract the colors we need from it later
 */
const linguistContent = linguistInitial as { content: string };
const linguistBuffer = Buffer.from(linguistContent.content as string, 'base64');
const linguist = YAML.parse(linguistBuffer.toString('utf-8'));

So after fetching the file contents and parsing it we now have access to all of the colors GitHub uses for its languages!

Now it's time to make use of the aforementioned https://github.com/davidbau/seedrandom library. I accept a custom seed string from the user or use the returned repository id value as a fallback.

/**
 * Initialize the random function with a custom seed so it's consistent
 * This accepts the query param and will otherwise fallback on the unique repository ID
 */
const random = seedrandom(seed || repository.id.toString());

I conditionally assign a template object some values depending on whether or not the user has requested a darkmode image be generated.

```js
/**
 * Remote Template Images
 */
const template = {
  base:
    displayParameter === 'light'
      ? await Jimp.read(fromAWS('/meta/base.png'))
      : await Jimp.read(fromAWS('/meta/base_dark.png')),
  dot:
    dotTypeParameter === 'circle'
      ? await Jimp.read(fromAWS('/meta/dot_black.png'))
      : await Jimp.read(fromAWS('/meta/square_black.png')),

  circle: await Jimp.read(fromAWS('/meta/dot_black.png')),
  square: await Jimp.read(fromAWS('/meta/square_black.png')),

  githubLogo:
    displayParameter === 'light'
      ? await Jimp.read(fromAWS('/meta/github_black.png'))
      : await Jimp.read(fromAWS('/meta/github_white.png')),
};

This carries into the font selection too.

/**
 * Font family used for writing
 */
const font =
  displayParameter === 'light'
    ? await Jimp.loadFont(
        'https://unpkg.com/@jimp/plugin-print@0.10.3/fonts/open-sans/open-sans-64-black/open-sans-64-black.fnt'
      )
    : await Jimp.loadFont(
        'https://unpkg.com/@jimp/plugin-print@0.10.3/fonts/open-sans/open-sans-64-white/open-sans-64-white.fnt'
      );

Nothing notable about this, just some boring variable reference definitions for both Jimp and so I didn't lose my mind with future calculations (you'll see what I mean). I specify the image dimensions as suggested by GitHub's social preview setting module and can definitely say that there's going to be 64 dots in each row (horizontal) and 32 dots in each column (vertical). I make it easy to not have to update both variables so I just divide by 20 on both. Spacing is basically the margins I use.

/**
 * Required Image Dimensions
 */
const width = 1280;
const height = 640;

/**
 * Total Count of Squares (Dimensions)
 */
const horizontal = width / 20;
const vertical = height / 20;

/**
 * Spacing
 */
const spacing = 40;

The next step I take is to make a dots array which will give me the total count of how many dots will be placed on the image. I will need to loop over this later. I also clone the base template for future reference and ensure it's resized to exactly what I need.

/**
 * Base Image
 */
const dots = [...new Array(horizontal * vertical)];
const image = template.base.clone().resize(width, height);

Every generated image has an area where dots cannot be placed. I decided to opt for a range. These are pixel coordinates from the top left of the image.

/**
 * Protected Area Coordinates
 */
const protectedArea = {
  x: { min: 185, max: 1085 },
  y: { min: 185, max: 445 },
};

I quickly started to need some helper functions to determine how to understand where each dot was going to be placed. For example, if I wanted to see where a dot with the index 129 would be placed I would pass it into this function.

const getXPosition = (index: number): number => {
  return 5 + (index % horizontal) * 20;
};

This applies the 5-pixel padding from the edge, runs the modulo operation against how many dots are in each row, and then scales that by 20px reserved space per dot (10px width and 10px gutter from the next dot). The same logic is applied vertically.

const getYPosition = (index: number): number => {
  return 5 + 20 * Math.floor(index / horizontal);
};

With these helper functions, I can now convert the protected area units into a simple boolean.

const isProtectedArea = (index: number): boolean => {
  if (
    getXPosition(index) > protectedArea.x.min &&
    getXPosition(index) < protectedArea.x.max &&
    getYPosition(index) > protectedArea.y.min &&
    getYPosition(index) < protectedArea.y.max
  ) {
    return true;
  }

  return false;
};

I take this one step further and make it so I can also see if a dot is within any of the immediately outer rings from the protected area and by what threshold.

const isInnerRing = (index: number, rows = 1): boolean => {
  if (
    getXPosition(index) > protectedArea.x.min - rows * 20 &&
    getXPosition(index) < protectedArea.x.max + rows * 20 &&
    getYPosition(index) > protectedArea.y.min - rows * 20 &&
    getYPosition(index) < protectedArea.y.max + rows * 20
  ) {
    return true;
  }

  return false;
};

rows is equal to one (1) by default which ultimately means that the 1 ring of dots that are touching the protected area will be labeled as truthy.

I use this to my advantage to generate a subtle fade out effect the closer you get to the inner rings and protected area.

const generateOpacity = (index: number, opacityStart: number): number => {
  const opacityBoost = 0.45;
  const opacity = opacityStart + opacityBoost > 1 ? 1 : opacityStart + opacityBoost;

  if (isInnerRing(index, 1)) return 0.4 * opacity;
  if (isInnerRing(index, 2)) return 0.4 * opacity;
  if (isInnerRing(index, 3)) return 0.5 * opacity;
  if (isInnerRing(index, 4)) return 0.5 * opacity;
  if (isInnerRing(index, 5)) return 0.6 * opacity;

  return opacity;
};

All of this preliminary work and helper functions, but we haven't even placed a single dot actually onto the image yet. So let's do that.

/**
 * Reducer Function for applying the dynamic generation logic to each dot
 */
const dotReducer = (_: unknown, __: unknown, index: number): void => {...}
dots.reduce(dotReducer, dots[0]);

You will have noticed that I have the dotsReducer function and that's where all the magic happens. Let's dive deeper.

First, we check if the dot index is going to put it into the protected area and immediately return if it is.

/**
 * If the index is within a protected area we already know to stop.
 * Nothing will be added in the protected region.
 */
if (isProtectedArea(index)) return;

We follow up with a reduction strategy of abusing seedrandom and reduce the amount of dots that will be placed by approximately 80%. This was a necessary step for making the image not feel busy and the true feeling of randomness in the final produced image.

/**
 * If we randomly generate a number between 1 and 100 and it's 80 or greater, let's just skip.
 * This reduces needlessly large amounts of squares from being added in a somewhat controlled way.
 * This basically gives us a percentage of reduction.
 */
const reduction = 80;
// const reduction = 0;
if (Math.floor(random() * 100) + 1 >= 100 - reduction) return;

If the iteration cycle survives an early return we're ready to clone the dot template referenced way earlier.

/**
 * If we haven't returned already, we're ready to proceed.
 */
const dot = template.dot.clone();

We're at the real magic now. It's time to assign a color to the dot based on the repository languages and with the appropriate weight applied to each one.

/**
 * Randomly select the language key we want to use to fetch our color.
 * Provide preference to the language-byte-counter provided by GitHub.
 */
const colorLanguageKey = weightedRandom(Object.entries(languages), random) as string;

/**
 * If it exists, pluck the Hexidecimal code we need for the language key randomly picked
 */
let { color: hexColor } = linguist[colorLanguageKey] as { color: string | undefined };

/**
 * Fix the hex color to a sensible default if it's undefined
 */
if (hexColor === undefined) {
  hexColor = 'cccccc';
}

/**
 * Convert the Hexidecimal value to a Jimp compatible RGB
 */
let color = hexToRgb(hexColor);

/**
 * Fix the color to a sensible default if it's null
 */
if (color === null) {
  color = hexToRgb('cccccc') as RGB;
}

The weighted random function accepts the values provided by the GitHub API response (language key and the associated byte count). You can see a version of this in my project https://github.com/pqt/weighted-random. This is how we get a dot color frequency that will roughly match that of the actual repository language percentages.

const weightedRandom = (items: [string, number][], randomizer: () => number = Math.random) => {
  /**
   * First, we loop the main dataset to count up the total weight. We're starting the counter at one because the upper boundary of Math.random() is exclusive.
   */
  let total = 1;
  for (let i = 0; i < items.length; ++i) {
    total += items[i][1];
  }

  /**
   * Total in hand, we can now pick a random value akin to our random index from before.
   */
  const threshold = Math.floor(randomizer() * total);

  /**
   * Now we just need to loop through the main data one more time
   * until we discover which value would live within this
   * particular threshold. We need to keep a running count of
   * weights as we go, so let's just reuse the "total" variable
   * since it was already declared.
   */
  total = 0;
  for (let i = 0; i < items.length; ++i) {
    /**
     * Add the weight to our running total.
     */
    total += items[i][1];

    /**
     * If this value falls within the threshold, we're done!
     */
    if (total >= threshold) {
      return items[i][0];
    }
  }
};

This implementation has been extended to add the randomizer: () => number = Math.random argument so I could pass in the seedrandom library so that once again it was consistent with the color assignment.

At this point, I'd either have a language color or the default #CCCCCC. I ran it through a handy hexToRGB function already so I destructure it and apply it to the dot.

/**
 * Destructure the color and apply it to the square
 */
const { red, green, blue } = color;
dot.color([
  { apply: 'red', params: [red] },
  { apply: 'green', params: [green] },
  { apply: 'blue', params: [blue] },
]);

Just because that wasn't enough already I decided to add a layer of randomness to the opacity too! The method for this was to apply a random opacity between 45 and 100% and then gradually reducing it between 40 and 60% as you got closer and closer to the protected area. I'd like to say there was some advanced math to justify these numbers and make it more impressive than it is but I'd be lying. There were completely arbitrary and ended up looking the best overall.

/**
 * Randomly generate (and validate) the opacity of our square.
 * The generateOpacity function adds the fade out effect towards to the center
 */
const opacity = generateOpacity(index, random());
dot.opacity(opacity);

With all this randomness we're ready to apply the modified dot to the image and continue to the next cycle.

/**
 * Our Square is now ready let's finally apply it to our image
 */
image.composite(dot, getXPosition(index), getYPosition(index));

Once the .reduce() function runs all 2,048 loops we end up with a completely unique generated image that can be consistently generated again and again.

Don't miss a new social-preview release

NewReleases is sending notifications on new releases.