Open Graph images & thumbnails
Render dynamic OG images and YouTube thumbnails from the same HTML pipeline you use for video.
Render a 1200×630 OG image — or a 1280×720 YouTube thumbnail — from the same HTML pipeline that produces your MP4s. One template, one variants file, one command per still.
What you'll learn
- Capturing a single frame as a PNG with
--codec png - Reusing your video template language for stills
- Templating thousands of OG images from a CMS
The result
The trick
HyperFrames already renders every frame as an image before muxing them into video. To get a still, render exactly one frame as PNG instead of running the encoder.
hyperframes render og.html \
--out og.png \
--codec png \
--width 1200 \
--height 630The composition can still use animations — only the first frame is captured, so the "frozen" pose at t=0 is your output. Compose with that in mind: design the keyframe, then add motion if you also want the animated version for elsewhere.
A real OG template
<!doctype html>
<html>
<head>
<style>
body { margin:0; width:1200px; height:630px;
background: linear-gradient(135deg, #0f172a 0%, #1e3a8a 100%);
color:#fff; font-family: ui-sans-serif, system-ui;
display:flex; flex-direction:column; justify-content:space-between;
padding:64px; box-sizing:border-box; }
.eyebrow { font-size:24px; letter-spacing:0.2em; text-transform:uppercase; opacity:.6; }
h2 { font-size:88px; line-height:1.05; letter-spacing:-0.03em; margin:0; max-width:900px; }
.foot { display:flex; justify-content:space-between; align-items:center; font-size:24px; }
.brand { font-weight:700; }
.tag { opacity:.7; }
</style>
</head>
<body>
<div class="eyebrow">{{$CATEGORY}}</div>
<h2>{{$TITLE}}</h2>
<div class="foot">
<div class="brand">hyperframes.dev</div>
<div class="tag">{{$AUTHOR}} · {{$DATE}}</div>
</div>
</body>
</html>YouTube thumbnails
Same template language, different viewport. YouTube wants 1280×720 with type readable at 168×94 (the smallest size it'll be served at). Two rules:
- Largest text element should be at least
font-size: 96px. - Background contrast should be at least 4.5:1 against the foreground text.
hyperframes render thumbnail.html \
--out thumb.png \
--codec png \
--width 1280 --height 720Wiring it to a CMS
For an MDX-powered blog, generate variants from the post list at build time:
// scripts/build-og.mjs
import { readdirSync, writeFileSync } from "node:fs";
import matter from "gray-matter";
const posts = readdirSync("content/blog")
.filter(f => f.endsWith(".mdx"))
.map(f => ({ slug: f.replace(/\.mdx$/, ""), ...matter.read(`content/blog/${f}`).data }));
const variants = posts.map(p => ({
out: `public/og/${p.slug}.png`,
vars: { TITLE: p.title, CATEGORY: p.category, AUTHOR: p.author, DATE: p.date }
}));
writeFileSync("og-variants.json", JSON.stringify(variants));Run it in CI before the static build. The OG images sit in public/og/{slug}.png and the <meta property="og:image"> tag in your post template points at them.
Tweak it
- Add a
BGtoken and pick a per-category color so categories are visually distinct in shares. - Render at 2400×1260 (2x) for retina displays, then let your CDN downscale.
- Use the same template with
--codec h264for an animated hero on the post page itself.