Link-preview images (og:image), 1200×630, drawn at build time with sharp: the site's card (a grid of covers beside the name) and one card per song (its cover beside its title, length and Jev's art score). Text goes through sharp's Pango renderer with a bundled font file, because the Nix build has no system fonts to fall back on.
cover.webp files by song id ("<theme>/<song>"), as paths sharp can read
11const coverFiles = Object.fromEntries( 12 Object.keys(import.meta.glob('../../../songs/*/*/cover.webp')) 13 .sort() 14 .map((rel) => [ 15 rel.split('/songs/')[1].replace(/\/cover\.webp$/, ''), 16 fileURLToPath(new URL(rel, import.meta.url)), 17 ]), 18); 19const font = fileURLToPath(new URL('../../public/fonts/FiraCode/FiraCode-SemiBold.ttf', import.meta.url));
Pango markup: escape the three characters it treats as syntax
26const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
Lays out text blocks top to bottom from top, gap pixels apart.
48const png = (layers) => 49 sharp({ create: { width: W, height: H, channels: 4, background: BACKGROUND } }) 50 .composite(layers) 51 .png() 52 .toBuffer(); 53 54export async function siteCard() { 55 const TILE = 210; // a 3×3 grid of covers, 630 px square, on the right 56 const gridX = W - 3 * TILE; 57 const tiles = await Promise.all( 58 Object.values(coverFiles) 59 .slice(0, 9) 60 .map(async (file, i) => ({ 61 input: await sharp(file).resize(TILE, TILE, { fit: 'cover' }).toBuffer(), 62 left: gridX + (i % 3) * TILE, 63 top: Math.floor(i / 3) * TILE, 64 })), 65 ); 66 const pad = 56; 67 const words = await column( 68 [ 69 { markup: span('#ffffff', 72, siteName), gap: 40 }, 70 { markup: span(themeColor, 36, siteTagline), gap: 60 }, 71 { 72 markup: span('#bbbbbb', 22, `${Object.keys(coverFiles).length} songs about Jev,\nwritten by an AI in Strudel`), 73 }, 74 ], 75 pad, 76 170, 77 gridX - 2 * pad, 78 ); 79 return png([...tiles, ...words]); 80} 81 82const duration = (s) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; 83 84export async function songCard(song) { 85 const cover = coverFiles[song.id]; 86 const layers = cover 87 ? [{ input: await sharp(cover).resize(H, H, { fit: 'cover' }).toBuffer(), left: 0, top: 0 }] 88 : []; 89 const pad = 48; 90 const blocks = [{ markup: span('#ffffff', 44, song.title), gap: 32 }]; 91 if (song.art) blocks.push({ markup: span(themeColor, 60, `${Math.round(song.art.p * 100)}% art`), gap: 8 }); 92 if (song.art) blocks.push({ markup: span('#999999', 18, `according to Jev (${song.art.model})`), gap: 32 }); 93 if (song.timing) 94 blocks.push({ 95 markup: span( 96 '#bbbbbb', 97 24, 98 `${song.timing.jev ? "Jev's call" : duration(song.timing.seconds)} · ${song.timing.bpm} bpm`, 99 ), 100 }); 101 layers.push(...(await column(blocks, H + pad, 70, W - H - 2 * pad))); 102 layers.push( 103 ...(await column( 104 [{ markup: span('#777777', 20, `${siteName} · ${siteTagline}`) }], 105 H + pad, 106 H - 70, 107 W - H - 2 * pad, 108 )), 109 ); 110 return png(layers); 111}