Skip to content

Instantly share code, notes, and snippets.

@vapvarun
Created June 4, 2026 04:11
Show Gist options
  • Select an option

  • Save vapvarun/9f2e9778811cd6dee0f8514d31f6ecdb to your computer and use it in GitHub Desktop.

Select an option

Save vapvarun/9f2e9778811cd6dee0f8514d31f6ecdb to your computer and use it in GitHub Desktop.
HTML + Playwright Featured Image Workflow (vapvarun.com)
"""
featured_image_screenshot.py
Generates a 1200x630 featured image from an HTML template
using Playwright. Converts to WebP at quality 85.
Requirements:
pip install playwright
playwright install chromium
brew install webp (macOS) | apt install webp (Linux)
Usage:
python3 featured_image_screenshot.py \
--html /tmp/vapvarun-featured/my-post.html \
--out /tmp/vapvarun-featured/my-post.webp
"""
import subprocess
import sys
from pathlib import Path
def screenshot_to_webp(html_path: str, out_path: str, quality: int = 85) -> str:
"""
1. Serve the HTML from a local HTTP server (avoids file:// restrictions).
2. Open Playwright at 1200x630, navigate, screenshot.
3. Convert the PNG to WebP with cwebp.
Returns the final WebP path.
"""
from playwright.sync_api import sync_playwright
import http.server
import threading
import os
html_path = Path(html_path).resolve()
serve_dir = html_path.parent
png_path = html_path.with_suffix(".png")
out_path = Path(out_path)
PORT = 8765
# Start local HTTP server in background thread
handler = http.server.SimpleHTTPRequestHandler
httpd = http.server.HTTPServer(("", PORT), handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
url = f"http://localhost:{PORT}/{html_path.name}"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1200, "height": 630})
page.goto(url, wait_until="networkidle")
page.screenshot(
path=str(png_path),
full_page=False, # Capture exactly the 1200x630 viewport
type="png",
)
browser.close()
finally:
httpd.shutdown()
# Convert PNG to WebP
result = subprocess.run(
["cwebp", "-q", str(quality), str(png_path), "-o", str(out_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"cwebp failed: {result.stderr}")
png_path.unlink() # Remove intermediate PNG
size_kb = out_path.stat().st_size / 1024
print(f"Output: {out_path} ({size_kb:.1f} KB)")
return str(out_path)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--html", required=True)
parser.add_argument("--out", required=True)
parser.add_argument("--quality", type=int, default=85)
args = parser.parse_args()
screenshot_to_webp(args.html, args.out, args.quality)
<!DOCTYPE html>
<!--
Featured Image Starter Template
Dimensions: 1200x630px (Open Graph standard)
Customize: gradient colors, typography, accent color, pill tags
Usage:
1. Replace {{POST_TITLE}}, {{TAG_1}}, {{TAG_2}}, {{SITE_NAME}} with your values
2. Adjust CSS variables in :root to match your brand
3. Screenshot with Playwright at 1200x630 viewport
4. Convert to WebP: cwebp -q 85 screenshot.png -o image.webp
-->
<html>
<head>
<meta charset="utf-8">
<style>
/* ============================================
BRAND VARIABLES -- change these per site
============================================ */
:root {
--bg-start: #FDF6EC; /* gradient start (top-left) */
--bg-end: #EDD89A; /* gradient end (bottom-right) */
--title-color: #1A0E05; /* main heading */
--accent-color: #3B1F0A; /* pills, accent bar, branding */
--eyebrow-color: #7A5C3A; /* site name / category label */
--font-title: Georgia, serif;
--font-ui: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px;
height: 630px;
overflow: hidden;
font-family: var(--font-title);
}
.card {
width: 1200px;
height: 630px;
background: linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 72px 80px;
}
/* Left accent bar */
.accent-bar {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 8px;
background: var(--accent-color);
}
/* Decorative circle, bottom-right */
.deco-circle {
position: absolute;
right: -100px; bottom: -100px;
width: 380px; height: 380px;
border-radius: 50%;
background: radial-gradient(circle, rgba(0,0,0,0.06) 0%, transparent 70%);
pointer-events: none;
}
.eyebrow {
font-family: var(--font-ui);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--eyebrow-color);
margin-bottom: 24px;
}
.title {
font-family: var(--font-title);
font-size: 54px;
line-height: 1.18;
font-weight: normal;
color: var(--title-color);
max-width: 820px;
margin-bottom: 40px;
}
/* Adjust font-size down for longer titles */
.title.long { font-size: 42px; }
.pills {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.pill {
font-family: var(--font-ui);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.06em;
color: var(--accent-color);
background: rgba(0,0,0,0.06);
border: 1px solid rgba(0,0,0,0.14);
border-radius: 20px;
padding: 5px 14px;
}
.branding {
position: absolute;
bottom: 28px;
right: 40px;
font-family: var(--font-ui);
font-size: 12px;
color: rgba(0,0,0,0.3);
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<div class="card">
<div class="accent-bar"></div>
<div class="deco-circle"></div>
<div class="eyebrow">{{SITE_NAME}}</div>
<h1 class="title">{{POST_TITLE}}</h1>
<div class="pills">
<span class="pill">{{TAG_1}}</span>
<span class="pill">{{TAG_2}}</span>
<!-- Add more pills as needed -->
</div>
<div class="branding">{{SITE_DOMAIN}}</div>
</div>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment