Dynamic open graph images

· erock's devlog

Creating on-the-fly open graph images to improve twitter engagement

listifi is an app that attempts to make lists viral. In order to increase engagement, it's important to make the open graph images when posting to social media dynamic, vibrant, and enticing enough to click on them.

Here is an example of the latest open graph images in listifi

listifi open graph image

In this article I discuss the different approaches I took to build dynamic, on-the-fly open graph images for listifi.

Inspiration #

There was a recent blog post by Github that discussed how they built their open graph images for github repositories.

github sentences open graph image

As you can see from the image above, it uses information from the repository to build the image. Not only does it include the name of the repo and description, it also includes things like stars, contributors and the languages used. This breathes life into their open graph images and makes you want to engage with them.

Their underlying implementation was to leverage puppeteer which is a headless chromium browser that takes a screenshot of their HTML. This works great because you can generate the screenshot using html, css.

I thought to myself, "if it's good enough for Github, it'll be good enough for listifi." So I started implementing my new open graph service using puppeteer.

Using puppeteer #

I won't discuss the intial implementation because it's basically how github described it.

I deployed this implementation and it worked, but it was really slow on my tiny f1.micro. Some images would take up to 5 seconds to generate. I decided to try solving the problem by vertically scaling my VM to a g1.small. That definitely helped but it only brought image generation time down by about 1 second.

I decided to add redis and store the image blob there so the first request will be slow, but every other request would be near instant. I also figured I could fire off a request to my service when the user navigates to the list detail page that hosts the image so when they actually share the list the image will already be cached. Here is the diff that adds redis.

After adding redis and getting the cache right things were very snappy. It was near instant. I decided to clear the cache for an image by storing an expiresAt timestamp set for the next day at the same time. This would allow images to get updated on a daily basis.

Great! I'm done, right? Not quite.

Using canvas #

A friend suggested I try to generate the image via SVG or using canvas and convert that to a PNG. As you can see with the puppeteer implementation, not only was it slow but I had to add a ton of dependencies to my stack as well as a redis server just to get adequate performance. canvas on the other hand, was much easier to install and setup only required a few external dependencies. It's possible with the canvas approach I wouldn't even need redis and the image could be generated on-the-fly for every request without any caching.

flavicopes article on using canvas.

The canvas implementation can be broken up into a few parts.

First we need to build the canvas and register our fonts:

 1log(`generating image for ${username}/${listname}`);
 2const width = 1200;
 3const height = 600;
 4const canvas = createCanvas(width, height);
 5const ctx = canvas.getContext("2d");
 6
 7registerFont("./public/OpenSans-Regular.ttf", {
 8  family: "opensans",
 9});
10registerFont("./public/OpenSans-SemiBold.ttf", {
11  family: "opensans",
12  weight: "bold",
13});
14
15ctx.fillStyle = "#fff";
16ctx.fillRect(0, 0, width, height);
17const marginX = 70;
18const widthLength = width - marginX * 2;

Since open graph images have a fixed height, it made the following calculations much easier to figure out. Next we need to start building the image.

1const titleFontSize = 70;
2ctx.font = `bold ${titleFontSize}px opensans`;
3ctx.fillStyle = "#3A3B3C";
4const titleText = list.name;
5const lines = wrapLines(ctx, titleText, widthLength).slice(0, 2);
6for (let i = 0; i < lines.length; i += 1) {
7  const line = lines[i];
8  ctx.fillText(line, marginX, 150 + titleFontSize * i);
9}

added title to open graph image

This is where I hit my first issue. How do you have text that will wrap automatically? I found a stack overflow post that figured out how to do it reliably:

 1function wrapLines(
 2  ctx: NodeCanvasRenderingContext2D,
 3  text: string,
 4  maxWidth: number,
 5) {
 6  const lines = [];
 7  let result = "";
 8  let i = 0;
 9  let j = 0;
10  let width = 0;
11
12  while (text.length) {
13    for (
14      i = text.length;
15      ctx.measureText(text.substr(0, i)).width > maxWidth;
16      i--
17    );
18
19    result = text.substr(0, i);
20
21    if (i !== text.length) {
22      for (
23        j = 0;
24        result.indexOf(" ", j) !== -1;
25        j = result.indexOf(" ", j) + 1
26      );
27    }
28
29    lines.push(result.substr(0, j || result.length));
30    width = Math.max(width, ctx.measureText(lines[lines.length - 1]).width);
31    text = text.substr(lines[lines.length - 1].length, text.length);
32  }
33
34  return lines;
35}

This will take the max width allowed and the original text and return an array of lines that ensures the text can fit inside the maxWidth. It worked exactly how I wanted it to so I didn't delve too deep into its implementation.

Another key piece here is that I only want to display a most 2 lines for both the title and the description. This was actually more difficult in html than it was with canvas. Because I had to figure out how to construct the lines based on the text and the max width, it was easy for me to slice the array and only return two lines. Nice.

Now I can build the description:

 1const subFontSize = 30;
 2ctx.font = `normal normal ${subFontSize}px opensans`;
 3ctx.fillStyle = "#666";
 4const descLines = wrapLines(ctx, list.description, widthLength).slice(0, 2);
 5for (let i = 0; i < descLines.length; i += 1) {
 6  const line = descLines[i];
 7  ctx.fillText(
 8    line,
 9    marginX,
10    150 + lines.length * titleFontSize + (subFontSize + 10) * i,
11  );
12}

added description to open graph image

This was essentially a repeat of the title except with a different font size and color. I had to do some simple arithmetic based on the position of the title text and the size of the font.

Now I can fill in the metrics and branding row:

 1ctx.fillStyle = "#3A3B3C";
 2const metricsY = 500;
 3const metricsPad = 180;
 4ctx.fillText(`${itemIds.length}`, marginX, metricsY);
 5ctx.fillText("items", marginX, metricsY + 35);
 6
 7ctx.fillText(`${list.stars}`, marginX + metricsPad, metricsY);
 8ctx.fillText("stars", marginX + metricsPad, metricsY + 35);
 9
10ctx.fillText(
11  `${Object.keys(comments).length}`,
12  marginX + metricsPad * 2,
13  metricsY,
14);
15ctx.fillText("comments", marginX + metricsPad * 2, metricsY + 35);
16
17const text = "listifi.app";
18const textWidth = ctx.measureText(text).width;
19ctx.fillText("listifi.app", 1200 - marginX - textWidth, metricsY + 20);

added metrics to open graph image

This was pretty simple, I just had to play around with the position of the row and the different metrics.

Finally, I need to display the rainbow branding that I use for listifi. In the app, this is generated by html and css, so I couldn't do that for my canvas implementation. Instead I decided to convert the rainbow into a PNG image with the exact dimensions I needed for this image (1200x10).

1const rainbow = await loadImage("./public/rainbow.png");
2ctx.drawImage(rainbow, 0, 580, 1200, 10);

finished open graph image

That was very easy to build. Shout out to photopea for making image editing in the browser easy. Being able to open a webpage to manipulate an image is an awesome developer experience.

Finally I just need to convert the canvas to a PNG.

1const buffer = canvas.toBuffer("image/png");

Using koa it's easy to send a Buffer object as the response to my API endpoint.

You can see the final implementation here

What did I gain by switching to canvas? #

The result is pretty great. Generating the image takes a fraction of a second without using any caching mechanism.

This was a pretty huge win for me. I was able to remove a ton of dependencies, remove a cache mechanism, and I can always generate a fresh open graph image for each list detail page.

Shout out to Antonio for coming up with this recommendation!

You can see the end result here


I have no idea what I'm doing. Subscribe to my rss feed to read more posts.