← Public packages
@kentcdodds/pocket-piano
A pocket piano for choir practice: your singing part's range on your phone, installable and offline.
scripts/render-og.mjs
111 lines · 8.5 KB · JavaScript// Renders the 1200x630 social preview (/og.jpg) with headless Chrome, the same way the
// Asteroids and Roadster share images are made: the art is HTML/SVG plus a real screenshot
// of the running app (real keys, real range strip, real middle C), and every word is live
// text, so nothing is AI generated. Writes src/og-image.ts (base64) and /tmp/pocket-piano-og.jpg.
//
// Usage: node scripts/render-og.mjs [appUrl]
// appUrl defaults to https://piano.kentcdodds.com/ (or a local `kody dev --app-base-path /` URL).
// Needs playwright-core resolvable from here (node_modules is gitignored) and a Chromium;
// set CHROME_PATH to use a specific browser. Fonts: Playfair Display and Inter installed.
import { chromium } from 'playwright-core'
import { writeFileSync } from 'node:fs'
const APP_URL = process.argv[2] || 'https://piano.kentcdodds.com/'
// Shown at the bottom of the image. Pass your own app URL (or set OG_SITE_LABEL) in a fork.
const SITE_LABEL = process.env.OG_SITE_LABEL || new URL(APP_URL).host
const browser = await chromium.launch(process.env.CHROME_PATH ? { executablePath: process.env.CHROME_PATH } : {})
// 1. Screenshot the real app on a phone-sized screen, tenor part, a C major chord held.
const phone = await browser.newPage({ viewport: { width: 400, height: 820 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true })
await phone.addInitScript(() => {
try { localStorage.setItem('pocket-piano:part', '"tenor"'); localStorage.setItem('pocket-piano:update-dismissed-all', '1') } catch (e) {}
})
await phone.goto(APP_URL, { waitUntil: 'networkidle' })
await phone.waitForFunction(() => window.pocketPiano && window.pocketPiano.state.keyPx > 0)
await phone.evaluate(() => {
document.querySelectorAll('.update-toast').forEach((el) => el.remove())
const chord = [60, 64, 67] // C4 E4 G4, shown pressed on the top row
const rows = document.querySelectorAll('#keyboard .row')
const top = rows[0]
chord.forEach((m) => top.querySelector('.key[data-midi="' + m + '"]').classList.add('down'))
document.getElementById('readout-note').textContent = 'C4'
document.getElementById('readout-hz').textContent = '261.6 Hz'
document.getElementById('readout').classList.add('has-note')
})
await phone.waitForTimeout(300)
const shot = (await phone.screenshot({ type: 'png' })).toString('base64')
await phone.close()
// 2. Compose the scene.
let seed = 9
const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647)
const bokeh = Array.from({ length: 26 }, () => {
const x = rnd() * 1200, y = 430 + rnd() * 220, r = 6 + rnd() * 26
const warm = rnd() > 0.35
return `<div class="bokeh" style="left:${x.toFixed(0)}px;top:${y.toFixed(0)}px;width:${(r * 2).toFixed(0)}px;height:${(r * 2).toFixed(0)}px;background:${warm ? 'rgba(242,181,68,' : 'rgba(92,200,168,'}${(0.08 + rnd() * 0.22).toFixed(2)});filter:blur(${(2 + rnd() * 6).toFixed(1)}px)"></div>`
}).join('')
const dust = Array.from({ length: 90 }, () => `<i style="left:${(640 + rnd() * 520).toFixed(0)}px;top:${(rnd() * 560).toFixed(0)}px;opacity:${(0.15 + rnd() * 0.6).toFixed(2)};transform:scale(${(0.5 + rnd() * 1.4).toFixed(2)})"></i>`).join('')
// A glowing staff that sweeps behind the phone, with a few notes on it.
const staff = [0, 1, 2, 3, 4].map((i) => `<path d="M-40 ${300 + i * 14} C 300 ${200 + i * 14}, 700 ${470 + i * 14}, 1260 ${250 + i * 14}" />`).join('')
const html = `<!doctype html><html><head><meta charset="utf-8"><style>
* { box-sizing: border-box; margin: 0; }
body { width: 1200px; height: 630px; overflow: hidden; background: #07080c; font-family: Inter, system-ui, sans-serif; }
.stage { position: absolute; inset: 0; overflow: hidden;
background:
radial-gradient(ellipse 520px 380px at 880px 330px, rgba(92,200,168,0.20), transparent 70%),
radial-gradient(ellipse 700px 420px at 300px 640px, rgba(242,181,68,0.16), transparent 70%),
radial-gradient(ellipse 900px 600px at 600px 300px, #151824, #07080c 75%); }
.beam { position: absolute; left: 680px; top: -120px; width: 520px; height: 900px; transform: rotate(14deg); transform-origin: top center;
background: linear-gradient(to bottom, rgba(255,240,210,0.30), rgba(255,240,210,0.07) 55%, transparent 80%);
clip-path: polygon(42% 0, 58% 0, 100% 100%, 0 100%); filter: blur(14px); mix-blend-mode: screen; }
.floor { position: absolute; left: 0; right: 0; top: 520px; bottom: 0; background: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,0.75)); }
.pool { position: absolute; left: 700px; top: 560px; width: 480px; height: 90px; border-radius: 50%; background: radial-gradient(ellipse at center, rgba(92,200,168,0.35), rgba(242,181,68,0.10) 45%, transparent 70%); filter: blur(10px); }
.bokeh { position: absolute; border-radius: 50%; }
.dust i { position: absolute; width: 2px; height: 2px; border-radius: 50%; background: #fff6e0; box-shadow: 0 0 4px #fff6e0; }
svg.staff { position: absolute; inset: 0; }
svg.staff path { fill: none; stroke: rgba(242,181,68,0.28); stroke-width: 1.4; }
.phone-wrap { position: absolute; left: 790px; top: 42px; width: 272px; height: 547px; perspective: 1400px; }
.phone { position: absolute; inset: 0; border-radius: 42px; padding: 11px; transform: rotateY(-22deg) rotateX(8deg) rotateZ(6deg);
background: linear-gradient(145deg, #3a3f4c, #0d0f14 40%, #1d2029 70%, #4a505e);
box-shadow: 0 0 0 1.5px rgba(255,255,255,0.10) inset, 0 40px 80px rgba(0,0,0,0.75), 0 0 90px rgba(92,200,168,0.35), 0 0 180px rgba(242,181,68,0.12); }
.screen { width: 100%; height: 100%; border-radius: 34px; overflow: hidden; position: relative; background: #14161c; }
.screen img { width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; }
.screen::after { content: ''; position: absolute; inset: 0; background: linear-gradient(115deg, rgba(255,255,255,0.16), rgba(255,255,255,0) 32%, rgba(255,255,255,0) 70%, rgba(255,255,255,0.05)); }
.notch { position: absolute; top: 20px; left: 50%; width: 12px; height: 12px; margin-left: -6px; border-radius: 50%; background: #05060a; z-index: 2; }
.copy { position: absolute; left: 72px; top: 0; bottom: 0; width: 640px; display: flex; flex-direction: column; justify-content: center; gap: 22px; }
.eyebrow { font-size: 17px; font-weight: 700; letter-spacing: 0.32em; text-transform: uppercase; color: #5cc8a8; text-shadow: 0 0 18px rgba(92,200,168,0.6); }
h1 { font-family: 'Playfair Display', Georgia, serif; font-weight: 800; font-size: 108px; line-height: 0.92; letter-spacing: -0.01em;
background: linear-gradient(180deg, #fffaf0 0%, #fbe7bb 55%, #f2b544 100%); -webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 0 22px rgba(242,181,68,0.35)) drop-shadow(0 4px 2px rgba(0,0,0,0.6)); }
.tag { font-size: 31px; line-height: 1.3; color: #e9ecf3; font-weight: 500; max-width: 560px; text-shadow: 0 2px 12px rgba(0,0,0,0.8); }
.parts { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
.parts span { font-size: 17px; font-weight: 600; color: #cfd5e2; padding: 7px 14px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.05); }
.parts span.on { color: #0e2620; background: #5cc8a8; border-color: #5cc8a8; box-shadow: 0 0 18px rgba(92,200,168,0.55); }
.url { position: absolute; left: 72px; bottom: 40px; font-size: 21px; font-weight: 600; letter-spacing: 0.06em; color: rgba(233,236,243,0.72); }
.url b { color: #f2b544; font-weight: 700; }
</style></head><body><div class="stage">
<svg class="staff" width="1200" height="630">${staff}</svg>
<div class="beam"></div>
${bokeh}
<div class="dust">${dust}</div>
<div class="floor"></div>
<div class="pool"></div>
<div class="phone-wrap"><div class="phone"><div class="screen"><img src="data:image/png;base64,${shot}" alt=""></div></div></div>
<div class="copy">
<div class="eyebrow">For singers and choirs</div>
<h1>Pocket Piano</h1>
<p class="tag">A piano in your pocket<br>for choir practice</p>
<div class="parts"><span>Soprano</span><span>Alto</span><span class="on">Tenor</span><span>Baritone</span><span>Bass</span></div>
</div>
<div class="url"><b>♪</b> ${SITE_LABEL}</div>
</div></body></html>`
const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 })
await page.setContent(html, { waitUntil: 'load' })
await page.evaluate(() => document.fonts.ready)
const jpeg = await page.screenshot({ type: 'jpeg', quality: 88, clip: { x: 0, y: 0, width: 1200, height: 630 } })
await browser.close()
writeFileSync('/tmp/pocket-piano-og.jpg', jpeg)
writeFileSync('src/og-image.ts', '// Generated by scripts/render-og.mjs. Do not edit by hand.\nexport const OG_IMAGE_JPEG = \'' + jpeg.toString('base64') + '\'\n')
console.log('og.jpg bytes', jpeg.length)