Dynamically generate og:image using skia-canvas in Remix πŸ’Ώ

Published:Β 
Last updated:Β 

The og:image of this article citing the title, my GitHub avatar picture and this sites domain

Table of contents

I had a problem!

Couple of months ago I created a first version of automatic og:image generation using Puppeteer taking screenshots on the server. Though I cached the images once generated the problem I ran into was the high RAM consumption of Puppeteer, so I increased the RAM of my fly app until I fixed it by disabling the on-server generation and moved almost the same code into a Git pre-commit hook using husky.

Which worked, but became pretty annoying pretty quickly, as I sometimes wanted to change and commit something without running the dev server which lead to commit errors as Puppeteer couldn't reach localhost 😩.

Canvas to the rescue πŸ–Ό

So another solution was needed, which I already had on my radar for quite some time, which is using canvas, even I kinda dislike(d) the API so far.

My inspiration to use canvas came from Cameron McHenry, Flavio Copes and Swyx.

Even though I kinda not-like the Canvas API as it feels pretty low level, primitive and is soo imperative πŸ˜΅β€πŸ’«

Like when you want to rotate an element on the canvas you have to first save the context, then rotate the whole context before placing the element on the rotated context and then restore the former state 😳 🀣

Luckily before getting started and just out of curiosity I googled for something like "NodeJS Skia", I think, which lead me to skia-canvas. Which I have to say is quite a lot more enjoyable, as it for example supports text-wrap out of the box. πŸ₯³

Immutable URLs

And this time I also planned for immutable caching and kept most of the logic in a separate file.

So for example the URL of the og:image for this article might look like this:

https://www.canrau.com/assets/images/og.png?v=4&size=default&rev=i3_KGR9q48&lang=en&slug=dynamic-social-images-skia-remix

In earlier versions I used Dynamic Segments like in the example below:

https://www.canrau.com/assets/en/ogimage/v4/default/dynamic-social-images-skia-remix.i3_KGR9q48.png

due to the fear that query parameters might not be universally supported as I remembered it from immutable asset URLs like for CSS files, that might be outdated.

though after chatting on Discord and doing some more research it seems this shouldn't be an issue, so I decided to switch over to a simpler route file /app/routes/assets/images/og[.png].ts and have the rest handled via URL Search Params, this way it'll also be easier to change details or adding features later on. πŸ₯³

I decided to move the resource route to its own subfolder assets, cause I might want to add a Cloudflare Page Rule targeting /assets/ or only /assets/images/.

Breakdown of the Query Parameters

v identifies the version of the og:image-generator, so when I update the design later on, I can just increment the version within the module and all URLs update automatically.

Then follows the size in this case default which equals 1200x630 which the whole internet mentions as the recommended size.
Not sure right now why I made it variable last time, but I kept it this way just in case I want to show a smaller one somewhere, might use them within the page via srcSet so the browser can decide which size to show.

lang specifies the requested content language. I could change my current content logic to get rid of it, but I think I wait at least for my CMS which might make this obsolete as well. Also when I switch to my CMS every article gets it's own ID so I might then even turn slug into id, because the slug doesn't have to affect the image at all. As it is right now, if I decide to rename a slug the image URL would change as well invalidating all caches. 😳

To identify the actual content slug (URL) reflects the post.

rev is a hash of the title, so when I decide to change the title later on, without changing the slug, the hash and therefore the URL changes to reflect that, which was the final piece to make the og:image URLs immutable. πŸ₯³

The Resource Route

Okay let's first look at the Resource Route which gets the request url, verifies all parameters, requests a new image from ogImageGenerator sets some headers and sends the response.

// app/routes/assets/images/og[.png].ts

import { redirect, type LoaderFunction } from "remix";
import matter from "gray-matter";
import { getContentPath, getFilePath } from "~/utils/compile-mdx.server";
import { readFile, revHash } from "~/utils.server";
import { domain, languages, defaultLang } from "/config";
import type { Lang } from "/types";
import { type Frontmatter } from "~/utils/mdx.server";
import {
  ogImageGenerator,
  defaultOgImageSize,
  supportedOgImageSizes,
  OG_IMAGE_VERSION,
  type Size,
} from "~/utils/ogImageGenerator";

export const loader: LoaderFunction = async ({ request }) => {
  const url = new URL(request.url);
  const slug = url.searchParams.get("slug") ?? "";
  const size = (url.searchParams.get("size") ?? "") as Size;
  const version = url.searchParams.get("v") ?? "";
  const rev = url.searchParams.get("rev") ?? "";
  const lang = (url.searchParams.get("lang") ?? "") as Lang;

  // if `slug` is missing we don't know what is requested so we stop right here and throw a 404
  if (!slug) {
    throw new Response("Not Found", { status: 404 });
  }

  // if `version` doesn't match, redirect to current `OG_IMAGE_VERSION`
  if (parseInt(version, 10) !== OG_IMAGE_VERSION) {
    url.searchParams.set("v", `${OG_IMAGE_VERSION}`);
    return redirect(url.toString(), 302);
  }

  // if size isn't recognised, redirect to default
  if (!supportedOgImageSizes.includes(size)) {
    url.searchParams.set("size", `${defaultOgImageSize}`);
    return redirect(url.toString(), 302);
  }

  // if language isn't recognised, redirect to default
  if (!languages.includes(lang)) {
    url.searchParams.set("lang", `${defaultLang}`);
    return redirect(url.toString(), 302);
  }

  const filename = `${lang}.mdx`;
  const contentPath = getContentPath(slug);
  const filePath = getFilePath(contentPath, filename);
  const source = await readFile(filePath, { encoding: "utf-8" }).catch(() => {
    throw new Response("Not Found", { status: 404 });
  });

  const { data } = matter(source) as unknown as { data: Frontmatter };
  const { status, title, author } = data;
  const titleHash = revHash(title);

  if (rev.toLowerCase() !== titleHash.toLowerCase()) {
    url.searchParams.set("rev", `${titleHash}`);
    return redirect(url.toString(), 302);
  }

  const buffer = await ogImageGenerator({ title, slug, lang, size, status, author });

  const contentDisposition = process.env.NODE_ENV === "development" ? "inline" : "attachment";

  const headers: HeadersInit = {
    "Content-Type": "image/png",
    "Access-Control-Expose-Headers": "Content-Disposition",
    "Content-Disposition": `${contentDisposition}; filename="${domain}_${slug}_${lang}_ogimage-${size}-v${OG_IMAGE_VERSION}.png"`,
    "x-content-type-options": "nosniff",
    // "Cache-Control": "public,max-age=31536000,immutable",
  };

  return new Response(buffer, { headers });
};

Also switching from dynamic segments to URL search params made the code a lot shorter, at least in this case and especially redirecting to defaults much cleaner.

So the loader get's all the needed parameters via the from the search query (slug, size, version, rev, lang), making sure they're strings and for Size & Lang even specific TypeScript types.

First, if there's no slug provided we straight up throw a 404 Not Found error as we have no way of knowing what to show.

If the og:image version isn't up to date, we redirect to the current OG_IMAGE_VERSION to always get the latest design.

If the size or language is not supported, we just redirect to the same URL with the size or language respectively changed to the default ones.

I might come back later and make them less sequential, but for now this does the job of not unnecessarily duplicating images from different URLs, which is what we want for our immutable URLs.

The language parameter and the verification if the language is supported at all is of course only necessary if you support, or plan to support, multiple languages.

Then we check out the content, get all needed information and verify the title is actually the requested one via the rev hash, if not redirect to the current one.

After all that is done, we pass in all the data into ogImageGenerator, set some headers and return the response.

In dev mode I need a quick feedback loop so I decided to set the Content-Disposition header to inline so that the image shows directly in the browser. In production it'll be set to attachment which indicates the browser to download the image with the specified name, instead of showing it. Beware that this only applies to when accesing the image straight from its direct URL in your browser, you can still embed images within your website as usual via he ` tag, no problem.

The Content-Disposition header is actually not needed for the functioning of the og:image, more a vanity thing. 😌

Generating the revision hash

// /app/utils.server.tsx

import { createHash, type BinaryToTextEncoding, type BinaryLike } from "node:crypto";

/**
 *
 * @param data input BinaryLike
 * @param encoding defaults to base64url
 * @returns string truncated to first 10 charaters
 *
 * @description inspired by [sindresorhus/rev-hash](https://github.com/sindresorhus/rev-hash)
 */
export function revHash(data: BinaryLike, encoding: BinaryToTextEncoding = "base64url") {
  return createHash("sha1").update(data).digest(encoding).slice(0, 10);
}

As it's not actually too important right now I just searched and based on this article decided to go with this hashing function instead of benchmarking myself.

The actual Open Graph Image Generator

// app/utils/ogImageGenerator.tsx

import { json } from "remix";
import { Canvas, loadImage, FontLibrary, type CanvasRenderingContext2D } from "skia-canvas";
import sharp from "sharp";
import { Lang } from "/types";
import { readFile, join } from "../utils.server";

export const OG_IMAGE_VERSION = 4;

export type Size = "default" | "small";

type SizeObj = {
  width: number;
  height: number;
  padding: number;
};

const sizes: Record<Size, SizeObj> = {
  small: { width: 504, height: 265, padding: 40 },
  default: { width: 1200, height: 630, padding: 20 },
} as const;

export const defaultOgImageSize = "default";
export const supportedOgImageSizes = Object.keys(sizes);

const rand = (n: number) => Math.floor(n * Math.random());

type OgImageGeneratorProps = {
  title: string;
  slug: string;
  lang: Lang;
  status?: string;
  author?: string;
  size: Size;
};

FontLibrary.use([join(process.cwd(), "app", "assets", "fonts", "TwemojiMozilla.ttf")]);
FontLibrary.use("Inter", [join(process.cwd(), "app", "assets", "fonts", "Inter.ttf")]);

export const ogImageGenerator = async ({
  title,
  slug,
  lang,
  size,
  status,
  author,
}: OgImageGeneratorProps) => {
  if (!title || !slug) return null;

  const { width, height, padding } = sizes.default;
  const avatarSize = Math.floor(width / 8);

  const canvas = new Canvas(width, height);
  const ctx = canvas.getContext("2d");
  ctx.clearRect(0, 0, width, height);

  const CENTER_X = width / 2;
  const titleMaxWidth = width - 100;
  const pixelsPerRow = height / 3;
  const desiredFontSize = Math.floor(width / 14);
  const minFontSize = 50;
  const [ignoredfontSize, fontSizeString] = calcFontSize(
    ctx,
    title,
    titleMaxWidth,
    desiredFontSize,
    minFontSize,
    pixelsPerRow + 90,
  );

  const gradient = ctx.createLinearGradient(20, 0, 220, 0);
  gradient.addColorStop(0, "#4942aa");
  gradient.addColorStop(1, "#5c55d9");

  // Set the fill style and draw a rectangle
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, width, height);

  // content background
  const whiteBorderSize = Math.floor(width / 63);

  // white border
  ctx.fillStyle = "#fff";
  ctx.fillRect(
    whiteBorderSize,
    whiteBorderSize,
    width - whiteBorderSize * 2,
    height - whiteBorderSize * 2,
  );

  const contentBackgroundSize = width / 60;
  ctx.fillStyle = "hsl(240, 2.5%, 15.7%)";
  ctx.fillRect(
    contentBackgroundSize,
    contentBackgroundSize,
    width - contentBackgroundSize * 2,
    height - contentBackgroundSize * 2,
  );

  if (title.toLowerCase().includes("fly.io")) {
    const flyLogoBuffer = await readFile(
      join(process.cwd(), "app", "assets", "fly.io_brandmark.png"),
    );
    const logo = await loadImage(flyLogoBuffer);
    const logoX = (width / 4.5) * 2;
    const logoY = (height / 3) * 2;
    const logoSize = 250;
    ctx.save();
    ctx.translate(logoSize / 2, logoSize / 2);
    ctx.rotate(convertToRadians(-20));
    ctx.globalAlpha = 0.45;
    ctx.drawImage(logo, logoX, logoY, logoSize, logoSize);
    ctx.restore();
  }

  ctx.shadowColor = "#000";
  ctx.shadowOffsetX = 7;
  ctx.shadowOffsetY = 7;
  ctx.shadowBlur = 20;

  ctx.font = `bold ${fontSizeString} "Twemoji Mozilla", Inter`;
  ctx.textAlign = "center";
  ctx.textBaseline = "top";
  ctx.fillStyle = "#fff";
  ctx.textWrap = true;
  ctx.fillText(title, CENTER_X, 70, titleMaxWidth);

  const img = await loadImage("https://github.com/canrau.png");
  const imageX = CENTER_X - avatarSize / 2;
  const imageY = pixelsPerRow + pixelsPerRow / 2 + padding;
  const avatarRadius = avatarSize / 2;
  const startAngle = 0;
  const endAngle = 2 * Math.PI;

  // image mask
  ctx.save();
  ctx.beginPath();
  ctx.arc(imageX + avatarSize / 2, imageY + avatarSize / 2, avatarRadius, startAngle, endAngle);
  ctx.fill();
  ctx.clip();

  ctx.shadowOffsetX = 4;
  ctx.shadowOffsetY = 4;
  ctx.shadowBlur = 13;
  ctx.drawImage(img, imageX, imageY, avatarSize, avatarSize);

  ctx.restore();

  ctx.font = `${Math.floor(width / 34)}pt Menlo`;
  ctx.textAlign = "center";
  ctx.textBaseline = "top";
  ctx.fillStyle = "#fff";
  const urlY = imageY + avatarSize + padding;
  ctx.shadowColor = "#000";
  ctx.shadowOffsetX = 5;
  ctx.shadowOffsetY = 5;
  ctx.shadowBlur = 5;
  ctx.fillText("canrau.com", CENTER_X, urlY, titleMaxWidth);

  let buffer = await canvas.toBuffer("png", { quality: 1 });

  if (Object.keys(sizes).includes(size) && size !== "default") {
    buffer = await sharp(buffer)
      .resize(sizes[size as Size])
      .toBuffer()
      .catch((e) => {
        console.error(e);
        throw json({ lang, error: "Error creating the image" }, 500);
      });
  }

  return buffer;
};

function calcFontSize(
  ctx: CanvasRenderingContext2D,
  title: string,
  maxWidth: number,
  desired: number,
  min: number,
  maxHeight: number,
  rounds: number = 0,
): [fontSize: number, fontSizeString: string] {
  const lineHeight = desired > 65 ? 1.2 : desired > 40 ? 1.4 : 1.5;
  if (rounds > 20) return [desired, `${desired}px/${lineHeight}`];

  ctx.font = `bold ${desired}pt Menlo`;
  ctx.textAlign = "center";
  ctx.textBaseline = "top";
  ctx.textWrap = true;

  const measures = ctx.measureText(title, maxWidth);

  if (measures.actualBoundingBoxDescent > maxHeight && desired - 1 >= min) {
    return calcFontSize(ctx, title, maxWidth, desired - 1, min, maxHeight, ++rounds);
  } else if (measures.actualBoundingBoxDescent > maxHeight) {
    return calcFontSize(ctx, title, maxWidth - 2, desired, min, maxHeight, ++rounds);
  }

  return [desired, `${desired}px/${lineHeight}`];
}

function convertToRadians(degree: number) {
  return degree * (Math.PI / 180);
}

If you're interested let me know and I'll get a little more into detail on the code. β€” @CanRau

Also, after trying to make the canvas responsive and keep failing I dropped all the calculations and use sharp now instead to resize the big one to whatever size I want, cause guess what, images scale naturally. 😎

Proper Emoji support

I've spent like ~7 hours trying to get emojis working.

Installing fonts-noto-color-emoji in my Dockerfile worked straight away, though many of its emojis look pretty ugly to me, especially the CD πŸ’Ώ which I use so frequently for Remix articles πŸ’πŸ»β€β™‚οΈ

So another solution had to be found, though I had really hard times getting eosrei/twemoji-color-font working, which seems to be the go to way for familiar emojis. πŸ₯²

Also tried Apple Color Emoji, sadly with no luck.

In the end I stumbled upon mozilla/twemoji-colr which finally solved it for me.
So you have to download the .ttf file from the release page and put it in your repo, I've put it in app/assets/fonts.

Please do yourselve a favor and don't look at my current Dockerfile 😳 I'm still way to worn out to clean that mess up πŸ₯²

Usage in your Route

To actually use the newly generated og:images in the content we have to add a little bit to all the desired route files where we want to include them.

Here's a stripped down version of a MetaFunction where I'm including the auto generated og:images:

// app/routes/$lang/__main/$slug.tsx

export const meta: MetaFunction = ({ data }) => {
  const {
    title: _title = "Missing Title",
    description = "Missing description",
    lang,
    slug,
    cover,
    meta,
  } = data?.frontmatter ?? {};

  const title = `${_title}${titleSeperator}${domain}`;
  const titleHash = revHash(_title);
  const url = `${rootUrl}/${lang}${slug}`;

  const ogImageUrl = new URL(rootUrl);
  ogImageUrl.pathname = "/assets/images/og.png";
  ogImageUrl.searchParams.set("v", OG_IMAGE_VERSION);
  ogImageUrl.searchParams.set("size", "default");
  ogImageUrl.searchParams.set("rev", titleHash);
  ogImageUrl.searchParams.set("slug", slug.replace(/^\//, ""));

  const ogImageMeta = {
    "og:image:url": cover ? `${rootUrl}${cover}` : ogImageUrl.toString(),
    "og:image:width": 1200,
    "og:image:height": 630,
    "og:image:type": "image/png",
    // todo: "og:image:alt":
  };

  return {
    title,
    description,
    "og:url": url,
    "og:title": title,
    "og:description": description,
    ...(image && ogImageMeta),
  };
};

Note on Dependencies

Make sure to check out the installation instructions of skia-canvas.

In my case I had to add additional dependencies to the base stage of my Dockerfile commit.

Yea we did it πŸ₯³

That's it for today, thanks a lot for reading.

I'm feeling like I'm probably soon-ish writing more about og:images πŸ˜… I've got still some more things in mind πŸ€“

Inspirations

Resources

Well, thanks for reading πŸ™ As always feel free to get in touch if you've got any questions or feedback 🀝

Can Rau
Can Rau

Doing web-development since around 2000, building my digital garden with a mix of back-to-the-roots-use-the-platform and modern edge-rendered-client-side-magic tech πŸ“»πŸš€

Living and working in Cusco, PerΓΊ πŸ¦™