1// Link-preview images (og:image), 1200×630, drawn at build time with sharp: 2// the site's card (a grid of covers beside the name) and one card per song 3// (its cover beside its title, length and Jev's art score). Text goes 4// through sharp's Pango renderer with a bundled font file, because the Nix 5// build has no system fonts to fall back on. 6import sharp from 'sharp'; 7import { fileURLToPath } from 'node:url'; 8import { siteName, siteTagline, themeColor } from './site.mjs'; 9 10// 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)); 20 21const W = 1200; 22const H = 630; 23const BACKGROUND = '#161616'; 24 25// Pango markup: escape the three characters it treats as syntax 26const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 27 28async function text(markup, width) { 29 return sharp({ text: { text: markup, fontfile: font, font: 'Fira Code SemiBold', width, rgba: true, dpi: 72 } }) 30 .png() 31 .toBuffer(); 32} 33 34const span = (color, size, content) => `<span foreground="${color}" size="${size}pt">${esc(content)}</span>`; 35 36// Lays out text blocks top to bottom from `top`, `gap` pixels apart. 37async function column(blocks, left, top, width) { 38 const out = []; 39 let y = top; 40 for (const { markup, gap = 24 } of blocks) { 41 const input = await text(markup, width); 42 out.push({ input, left, top: y }); 43 y += (await sharp(input).metadata()).height + gap; 44 } 45 return out; 46} 47 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}