jevstrudel.git / website / src / jev / og.mjs
og.mjsannotatedog.mjssource111 lines · 3.8 KB · raw
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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}