jevstrudel.git / website / src / jev / og.mjs
og.mjsannotatedog.mjssource111 lines · 3.8 KB · raw

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.

6import sharp from 'sharp';
7import { fileURLToPath } from 'node:url';
8import { siteName, siteTagline, themeColor } from './site.mjs';

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));
21const W = 1200;
22const H = 630;
23const BACKGROUND = '#161616';

Pango markup: escape the three characters it treats as syntax

26const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>`;

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}
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}