Generative OpenGraph Images
One of the creators that always stands out on my twitter feed is Seth Godin @ThisIsSethsBlog. Seth himself is not active on Twitter, but, he does use it to drive traffic to his site by retweeting his blog posts.
What stands out though is the use of an alternating photo of him accompanied by the bright yellow/orange (yorange?) color and the simple “seths.blog”.
On occasion though this image is missing and it made me think, what does their workflow look like for publishing a post? How could we build an OpenGraph image to automate this workflow and, maybe, add more variation?
Let’s start with the OpenGraphImage.com url which will look something like this:
https://cdn.opengraphimage.com/zF3O_eUZWAlTGuVBLynO8w/generative/?path=%2F2022%2F09%2Fthe-speed-of-change%2F&title=The%20speed%20of%20change&s=545b4dbd3cae0de50299119b5f641ac6
Notice we are passing “The speed of change” in via the title
querystring parameter. We are also passing the path
of the article so we can link to it with link.opengraphimage.com
. In the future we can
use the path
parameter to programatically lookup details about the post and add additional creative elements like publish date.
Consistently Random
The first constraint that occured to me is the photo of Seth. It’s easy enough to randomly select an image from a list, however, we don’t want that image to change everytime the opengraph image is generated. The image should be tied to the article for consistency.
To accomplish this we can generate a seed from the text content of the article or title. In javascript, this will look something like:
const generateSeed = (string, defaultSeed = 0) => {
if (string) {
// Convert the string to a character array, calculate the character code and return the sum.
return [...string].map(c => c.charCodeAt(0)).reduce((a, b) => a + b);
}
return defaultSeed;
};
Calling this method with an article title of “The speed of change” will give us a number of 1741
.
generateSeed('The speed of change') // => 1741
Now, let’s say we have 10
photos to choose from, we would calculate the “random” photo for this article with a modulo operation 1741 % 10
which
gives us 1
. Now, as long as we don’t add more photos to choose from, we will always get 1
for the string The speed of change
which we can use to
get the the item from the photos array… photos[1]
.
We can use this same method to dynamically change the “blob” mask of the photo.
Putting it all together
Using our getting started template as a base. We can use Tailwindcss and Alpinejs to build our image.
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs" defer></script>
</head>
<body class="h-screen">
<div class="flex items-center h-full bg-neutral-800">
<div class="relative w-[1200px] h-[600px] overflow-hidden mx-auto bg-white text-black">
<div class="flex items-center justify-between w-full h-full p-28" x-data="openGraphImage">
<div class="relative flex-none w-1/3">
<div class="aspect-w-1 aspect-h-1">
<img
class="object-cover w-full h-full"
:src="photo"
:style="{
'-webkit-mask-image': `url(${shape})`,
'mask-image': `url(${shape})`,
'-webkit-mask-repeat': 'no-repeat',
'mask-repeat': 'no-repeat',
'-webkit-mask-size': 'contain',
'mask-size': 'contain',
}"
/>
</div>
</div>
<div class="flex-none w-2/3 text-right">
<div class="text-7xl">
<div x-text="title"></div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('openGraphImage', () => {
const params = new URLSearchParams(window.location.search);
return {
params: Object.fromEntries(params.entries()),
get seed() {
return [...this.title].map(c => c.charCodeAt(0)).reduce((a, b) => a + b);
},
get title() {
return this.params.title || 'seths.blog';
},
get path() {
return this.params.path || '/';
},
get shape() {
const shapes = [
'shape-00.png',
'shape-01.png',
'shape-02.png',
'shape-03.png',
'shape-04.png',
'shape-05.png',
'shape-06.png',
'shape-07.png',
];
return shapes[this.seed % shapes.length];
},
get photo() {
const photos = [
'face-00.jpg',
'face-01.jpg',
'face-02.jpg',
'face-03.jpg',
'face-04.jpg',
'face-05.jpg',
'face-06.jpg',
'face-07.jpg',
];
return photos[this.seed % photos.length];
},
init() {
window.__og = window.__og || [];
// set the redirect url for `link.opengraphimage.com`
window.__og.push(['link', `https://seths.blog${this.path}`]);
// ensure all assets are loaded before rendering
window.__og.push(['preload', 'fonts', this.shape, this.photo]);
// wait for alpine to finish doing it's thing
this.$nextTick(() => {
// let the opengraphimage.com renderer know we're ready
window.__og.push(['ready']);
});
},
};
});
});
</script>
</body>
</html>