Dynamically generate Opengraph images for each market (#21)

* Start customizing opengraph image

* Fix cover image size to 1200x630

* Design a text-based, dynamic preview card

* Load designed html into template.ts

Remove twemoji for now too

* Take in params to configure Manifold card

* Switch back to hardcoded png for default case
This commit is contained in:
Austin Chen 2022-01-09 21:50:31 -08:00 committed by GitHub
parent 179fa8c608
commit ed37d93868
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 258 additions and 79 deletions

View File

@ -19,7 +19,7 @@ export async function getScreenshot(
isDev: boolean isDev: boolean
) { ) {
const page = await getPage(isDev); const page = await getPage(isDev);
await page.setViewport({ width: 2048, height: 1170 }); await page.setViewport({ width: 1200, height: 630 });
await page.setContent(html); await page.setContent(html);
const file = await page.screenshot({ type }); const file = await page.screenshot({ type });
return file; return file;

View File

@ -5,7 +5,22 @@ import { ParsedRequest } from "./types";
export function parseRequest(req: IncomingMessage) { export function parseRequest(req: IncomingMessage) {
console.log("HTTP " + req.url); console.log("HTTP " + req.url);
const { pathname, query } = parse(req.url || "/", true); const { pathname, query } = parse(req.url || "/", true);
const { fontSize, images, widths, heights, theme, md } = query || {}; const {
fontSize,
images,
widths,
heights,
theme,
md,
// Attributes for Manifold card:
question,
probability,
metadata,
creatorName,
creatorUsername,
creatorAvatarUrl,
} = query || {};
if (Array.isArray(fontSize)) { if (Array.isArray(fontSize)) {
throw new Error("Expected a single fontSize"); throw new Error("Expected a single fontSize");
@ -26,6 +41,15 @@ export function parseRequest(req: IncomingMessage) {
text = arr.join("."); text = arr.join(".");
} }
// Take a url query param and return a single string
const getString = (stringOrArray: string[] | string | undefined): string => {
if (Array.isArray(stringOrArray)) {
// If the query param is an array, return the first element
return stringOrArray[0];
}
return stringOrArray || "";
};
const parsedRequest: ParsedRequest = { const parsedRequest: ParsedRequest = {
fileType: extension === "jpeg" ? extension : "png", fileType: extension === "jpeg" ? extension : "png",
text: decodeURIComponent(text), text: decodeURIComponent(text),
@ -35,6 +59,15 @@ export function parseRequest(req: IncomingMessage) {
images: getArray(images), images: getArray(images),
widths: getArray(widths), widths: getArray(widths),
heights: getArray(heights), heights: getArray(heights),
question:
getString(question) || "Will you create a prediction market on Manifold?",
probability: getString(probability) || "85",
metadata: getString(metadata) || "Jan 1  •  M$ 123 pool",
creatorName: getString(creatorName) || "Manifold Markets",
creatorUsername: getString(creatorUsername) || "ManifoldMarkets",
creatorAvatarUrl:
getString(creatorAvatarUrl) || "https://manifold.markets/logo.png",
}; };
parsedRequest.images = getDefaultImages(parsedRequest.images); parsedRequest.images = getDefaultImages(parsedRequest.images);
return parsedRequest; return parsedRequest;

View File

@ -1,20 +1,5 @@
import { readFileSync } from "fs";
import marked from "marked";
import { sanitizeHtml } from "./sanitizer"; import { sanitizeHtml } from "./sanitizer";
import { ParsedRequest } from "./types"; import { ParsedRequest } from "./types";
const twemoji = require("twemoji");
const twOptions = { folder: "svg", ext: ".svg" };
const emojify = (text: string) => twemoji.parse(text, twOptions);
const rglr = readFileSync(
`${__dirname}/../_fonts/Inter-Regular.woff2`
).toString("base64");
const bold = readFileSync(`${__dirname}/../_fonts/Inter-Bold.woff2`).toString(
"base64"
);
const mono = readFileSync(`${__dirname}/../_fonts/Vera-Mono.woff2`).toString(
"base64"
);
function getCss(theme: string, fontSize: string) { function getCss(theme: string, fontSize: string) {
let background = "white"; let background = "white";
@ -30,36 +15,12 @@ function getCss(theme: string, fontSize: string) {
return ` return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: normal;
src: url(data:font/woff2;charset=utf-8;base64,${rglr}) format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: bold;
src: url(data:font/woff2;charset=utf-8;base64,${bold}) format('woff2');
}
@font-face {
font-family: 'Vera';
font-style: normal;
font-weight: normal;
src: url(data:font/woff2;charset=utf-8;base64,${mono}) format("woff2");
}
body { body {
background: ${background}; background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px; background-size: 100px 100px;
height: 100vh; height: 100vh;
display: flex; font-family: "Readex Pro", sans-serif;
text-align: center;
align-items: center;
justify-content: center;
} }
code { code {
@ -108,50 +69,92 @@ function getCss(theme: string, fontSize: string) {
font-style: normal; font-style: normal;
color: ${foreground}; color: ${foreground};
line-height: 1.8; line-height: 1.8;
}`; }
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`;
} }
export function getHtml(parsedReq: ParsedRequest) { export function getHtml(parsedReq: ParsedRequest) {
const { text, theme, md, fontSize, images, widths, heights } = parsedReq; const {
theme,
fontSize,
question,
probability,
metadata,
creatorName,
creatorUsername,
creatorAvatarUrl,
} = parsedReq;
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<meta charset="utf-8"> <head>
<title>Generated Image</title> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<style> <style>
${getCss(theme, fontSize)} ${getCss(theme, fontSize)}
</style> </style>
<body> <body>
<div> <div class="px-24">
<div class="spacer"> <!-- Profile image -->
<div class="logo-wrapper"> <div class="absolute left-24 top-8">
${images <div class="flex flex-row align-bottom gap-6">
.map( <img
(img, i) => class="h-24 w-24 rounded-full bg-white flex items-center justify-center"
getPlusSign(i) + getImage(img, widths[i], heights[i]) src="${creatorAvatarUrl}"
) alt=""
.join("")} />
</div> <div class="flex flex-col">
<div class="spacer"> <p class="text-gray-900 text-3xl">${creatorName}</p>
<div class="heading">${emojify( <p class="text-gray-500 text-3xl">@${creatorUsername}</p>
md ? marked(text) : sanitizeHtml(text) </div>
)}
</div>
</div> </div>
</body> </div>
<!-- Mantic logo -->
<div class="absolute right-24 top-8">
<a class="flex flex-row gap-3" href="/"
><img
class="sm:h-12 sm:w-12"
src="https:&#x2F;&#x2F;manifold.markets&#x2F;logo.png"
width="40"
height="40"
/>
<div
class="hidden sm:flex font-major-mono lowercase mt-1 sm:text-3xl md:whitespace-nowrap"
>
Manifold Markets
</div></a
>
</div>
<div class="flex flex-row justify-between gap-12 pt-36">
<div class="text-indigo-700 text-6xl leading-snug">
${question}
</div>
<div class="flex flex-col text-primary">
<div class="text-8xl">${probability}%</div>
<div class="text-4xl">chance</div>
</div>
</div>
<!-- Metadata -->
<div class="absolute bottom-16">
<div class="text-gray-500 text-3xl">
${metadata}
</div>
</div>
</div>
</body>
</html>`; </html>`;
} }
function getImage(src: string, width = "auto", height = "225") {
return `<img
class="logo"
alt="Generated Image"
src="${sanitizeHtml(src)}"
width="${sanitizeHtml(width)}"
height="${sanitizeHtml(height)}"
/>`;
}
function getPlusSign(i: number) {
return i === 0 ? "" : '<div class="plus">+</div>';
}

View File

@ -10,4 +10,12 @@ export interface ParsedRequest {
images: string[]; images: string[];
widths: string[]; widths: string[];
heights: string[]; heights: string[];
// Attributes for Manifold card:
question: string;
probability: string;
metadata: string;
creatorName: string;
creatorUsername: string;
creatorAvatarUrl: string;
} }

View File

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<style>
@import url("https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap");
body {
background: white;
background-image: radial-gradient(
circle at 25px 25px,
lightgray 2%,
transparent 0%
),
radial-gradient(circle at 75px 75px, lightgray 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #d400ff;
font-family: "Vera";
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before,
code:after {
content: "`";
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #bbb;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 0.05em 0 0.1em;
vertical-align: -0.1em;
}
.heading {
font-family: "Major Mono Display", monospace;
font-size: 100px;
font-style: normal;
color: black;
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
</style>
<body>
<div class="px-24">
<!-- Profile image -->
<div class="absolute left-24 top-8">
<div class="flex flex-row align-bottom gap-6">
<img
class="h-24 w-24 rounded-full bg-gray-400 flex items-center justify-center"
src="https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c"
alt=""
/>
<div class="flex flex-col">
<p class="text-gray-900 text-3xl">Austin Chen</p>
<p class="text-gray-500 text-3xl">@AustinChen</p>
</div>
</div>
</div>
<!-- Mantic logo -->
<div class="absolute right-24 top-8">
<a class="flex flex-row gap-3" href="/"
><img
class="sm:h-12 sm:w-12"
src="https:&#x2F;&#x2F;manifold.markets&#x2F;logo.png"
width="40"
height="40"
/>
<div
class="hidden sm:flex font-major-mono lowercase mt-1 sm:text-3xl md:whitespace-nowrap"
>
Manifold Markets
</div></a
>
</div>
<div class="flex flex-row justify-between gap-12 pt-36">
<div class="text-indigo-700 text-6xl leading-snug">
Will Manifold switch its logo to a manatee by April?
</div>
<div class="flex flex-col text-primary">
<div class="text-8xl">30%</div>
<div class="text-4xl">chance</div>
</div>
</div>
<!-- Metadata -->
<div class="absolute bottom-16">
<div class="text-gray-500 text-3xl">
Jan 7 &nbsp;&nbsp; Closes Mar 31, 9:59pm &nbsp;&nbsp; M$ 448 pool
&nbsp;&nbsp; #ManifoldMarkets #fun
</div>
</div>
</div>
</body>
</html>

View File

@ -30,7 +30,7 @@ function MyApp({ Component, pageProps }: AppProps) {
<meta name="twitter:site" content="@manifoldmarkets" /> <meta name="twitter:site" content="@manifoldmarkets" />
<meta <meta
property="og:image" property="og:image"
content="https://manifold-og-image.vercel.app/manifold%20markets.png" content="https://manifold.markets/logo-cover.png"
/> />
<meta <meta
name="twitter:image" name="twitter:image"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 11 KiB