chore(vk): keep the two chosen covers, drop the rest
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped

The dark and light covers move up beside the generator as the shipped assets;
the six exploratory variants and the contact sheet go.

Trims the generator to match: VARIANTS is now only the two covers, and with the
retired variants gone so are the knobs nothing reaches any more — the wordmark
caption, the branded palette, the whole-board height fit and the non-brand tile
face. Left behind, they would read as supported options that no output exercises.

`node build-cover.mjs` now reproduces exactly the two committed PNGs.
This commit is contained in:
Ilia Denisov
2026-07-27 15:59:40 +02:00
parent 70b88c567f
commit b53e23fe4d
10 changed files with 24 additions and 175 deletions
+11 -5
View File
@@ -3,14 +3,20 @@
The cover art for the VK community (`1920×768`). The community avatar — the «Э» tile — is
uploaded separately; this is only the backdrop behind it.
`build-cover.mjs` is the whole thing: a small software renderer that draws an Эрудит board in
perspective with the opening `ГРАМОТНОСТЬ / СОПЕРНИК / ЭРУДИЦИЯ / УМ` already played, and
emits one PNG per art-direction variant plus a contact sheet.
| File | What |
|------|------|
| `cover-night-spot.png` | the dark cover — board in a warm spotlight |
| `cover-day-spot.png` | the light cover — same camera, same crop, daylight |
| `build-cover.mjs` | the generator both are rendered by |
`build-cover.mjs` is a small software renderer that draws an Эрудит board in perspective with
the opening `ГРАМОТНОСТЬ / СОПЕРНИК / ЭРУДИЦИЯ / УМ` already played, and writes the two PNGs
next to itself.
```sh
cd assets/vk/cover
node build-cover.mjs # every variant -> preview/
node build-cover.mjs night-spot # one variant
node build-cover.mjs # both covers
node build-cover.mjs night-spot # one of them
FAST=1 node build-cover.mjs # 1x instead of 2x supersampling, for quick iteration
ZONES=1 node build-cover.mjs # overlay the VK keep-outs on the render
LAYOUT=cross node build-cover.mjs # the alternative word arrangement
+13 -170
View File
@@ -9,8 +9,8 @@
// Tile thickness is drawn as real 3D quads between the two warped planes.
//
// Usage:
// node build-cover.mjs # every variant -> preview/cover-<id>.png
// node build-cover.mjs warm-table # one variant
// node build-cover.mjs # both covers -> cover-<id>.png
// node build-cover.mjs night-spot # one of them
//
// skia-canvas is not installed here: the render sidecar already vendors it and this is a
// one-off asset build, so we borrow that copy rather than adding a second native module.
@@ -18,7 +18,6 @@
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { mkdirSync } from 'node:fs';
const DIR = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
@@ -177,26 +176,19 @@ function makeCamera({ elevDeg, dist, focal, cx, cy }) {
/**
* frameCamera turns the art-direction knobs into a camera: elevDeg and dist set the look,
* widthFrac says how much of the output width the board's NEAR edge — its widest point on
* screen, so widthFrac ≤ 1 keeps the whole board inside the frame — should span, and
* screen — should span, and
* (aimY, aimXFrac, aimYFrac) put the board-space row aimY at that point of the frame, which
* is how a variant keeps the played words centred while the board itself bleeds off-canvas.
*/
function frameCamera(W, H, c) {
// Depth of a board-plane row: Zc(y, 0) = dist + y·cos(elev); the near edge is the widest.
const nearZ = c.dist - HALF_BOARD * Math.cos((c.elevDeg * Math.PI) / 180);
let focal = ((c.widthFrac * W) / (2 * HALF_BOARD)) * nearZ;
const focal = ((c.widthFrac * W) / (2 * HALF_BOARD)) * nearZ;
// With cy = 0 the projected y is exactly proportional to the focal length, so the focal
// that makes the whole board fit a given share of the frame height is a closed form — and
// a fitted variant simply takes whichever of the two limits binds first.
// With cy = 0 the projected y is exactly proportional to the focal length, so aiming is a
// closed form: project the target row through a unit-focal camera and scale by the focal.
const unit = makeCamera({ elevDeg: c.elevDeg, dist: c.dist, focal: 1, cx: 0, cy: 0 });
const uFar = unit.project(0, HALF_BOARD, 0)[1];
const uNear = unit.project(0, -HALF_BOARD, 0)[1];
if (c.fitHeightFrac) focal = Math.min(focal, (c.fitHeightFrac * H) / (uNear - uFar));
const cy = c.fitHeightFrac
? H / 2 - (focal * (uFar + uNear)) / 2
: (c.aimYFrac ?? 0.5) * H - focal * unit.project(0, c.aimY ?? 0, 0)[1];
const cy = (c.aimYFrac ?? 0.5) * H - focal * unit.project(0, c.aimY ?? 0, 0)[1];
// aimX is a board column placed at aimXFrac, so a variant can slide the played words in
// the frame without dragging the whole slab off one side.
const cx = (c.aimXFrac ?? 0.5) * W - focal * unit.project(c.aimX ?? 0, c.aimY ?? 0, 0)[0];
@@ -384,13 +376,12 @@ function boardTexture(pal, grid) {
/** tileTexture renders only the tile faces (letter + value), aligned to the same board
* rectangle as boardTexture so it can be warped as the plane at tile height. */
function tileTexture(pal, grid, opts) {
function tileTexture(pal, grid) {
const cv = new Canvas(TEX_SIDE, TEX_SIDE);
const ctx = cv.getContext('2d');
const inset = TEX_CELL * TILE_INSET;
const w = TEX_CELL - 2 * inset;
const rad = TEX_CELL * 0.12;
const brand = opts.font === 'brand';
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
@@ -429,7 +420,7 @@ function tileTexture(pal, grid, opts) {
// The glyph is placed by its INK box, not by the text origin, so every letter lines up
// on the same corner regardless of its side bearings and ascent.
ctx.fillStyle = pal.tileText;
ctx.font = brand ? `${TEX_CELL * 0.74}px EruditBrand` : `700 ${TEX_CELL * 0.66}px DejaVu Sans`;
ctx.font = `${TEX_CELL * 0.74}px EruditBrand`;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
const m = ctx.measureText(letter);
@@ -456,7 +447,7 @@ const SLAB = 0.55;
/**
* drawScene composes one cover onto ctx at the given supersampled size: background, board
* slab, warped board surface, extruded tile sides, warped tile faces, then the variant's
* post effects and optional caption. `step` is the warp band height in device pixels.
* post effects. `step` is the warp band height in device pixels.
*/
function drawScene(ctx, W, H, v, grid, step) {
const pal = v.palette;
@@ -532,32 +523,9 @@ function drawScene(ctx, W, H, v, grid, step) {
}
}
warpPlane(ctx, freeze(tileTexture(pal, grid, { font: v.font ?? 'brand' })), cam, th, HALF_BOARD, step, H);
warpPlane(ctx, freeze(tileTexture(pal, grid)), cam, th, HALF_BOARD, step, H);
v.effects?.(ctx, W, H, pal);
if (v.caption) drawCaption(ctx, W, H, v);
}
/** drawCaption sets the variant's wordmark block. VK overlays the community avatar over the
* bottom-left of the cover on mobile, so a caption stays out of that corner. */
function drawCaption(ctx, W, H, v) {
const cap = v.caption;
const x = cap.xFrac * W;
const y = cap.yFrac * H;
ctx.save();
ctx.textAlign = cap.align ?? 'left';
ctx.textBaseline = 'alphabetic';
ctx.shadowColor = 'rgba(0,0,0,0.6)';
ctx.shadowBlur = W * 0.01;
ctx.fillStyle = cap.color;
ctx.font = `${H * 0.15}px EruditBrand`;
ctx.fillText(cap.text, x, y);
if (cap.sub) {
ctx.font = `500 ${H * 0.045}px DejaVu Sans`;
ctx.globalAlpha = 0.85;
ctx.fillText(cap.sub, x, y + H * 0.085);
}
ctx.restore();
}
// ------------------------------------------------------------------- background helpers
@@ -634,16 +602,6 @@ const NIGHT = {
sideLat: '#bda372',
};
const BRANDED = {
...WARM,
frame: '#c9974a',
cell: '#eef2ef',
cellDark: '#e2e8e4',
grid: 'rgba(0,0,0,0.08)',
tile: '#f9edd0',
sideNear: '#cfae72',
sideLat: '#e0c48f',
};
// ------------------------------------------------------------------------------ variants
@@ -652,26 +610,10 @@ const BRANDED = {
const WORDS_Y = HALF_GRID - 10;
const VARIANTS = [
{
id: 'warm-table',
title: 'Тёплый стол — дневной свет, доска целиком',
palette: WARM,
font: 'brand',
cam: { elevDeg: 34, dist: 30, widthFrac: 0.94, fitHeightFrac: 0.94 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#f7eee2'], [0.55, '#e9d8c2'], [1, '#c9a880']], 0, 0, 0, H);
radialGlow(ctx, W, H, W * 0.74, H * 0.06, W * 0.8, 'rgba(255,247,228,0.9)', 'rgba(255,247,228,0)');
},
effects(ctx, W, H) {
farHaze(ctx, W, H, '247,238,226', 0.4, 0.45);
vignette(ctx, W, H, 0.26);
},
},
{
id: 'night-spot',
title: 'Ночь — доска в луче света, слова крупно',
palette: NIGHT,
font: 'brand',
cam: { elevDeg: 27, dist: 24, widthFrac: 1.4, aimX: 0.8, aimY: WORDS_Y, aimYFrac: 0.56 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#0c1411'], [0.6, '#131e19'], [1, '#070c0a']], 0, 0, 0, H);
@@ -687,7 +629,6 @@ const VARIANTS = [
id: 'day-spot',
title: 'День — тот же кадр, светлая пара к night-spot',
palette: WARM,
font: 'brand',
cam: { elevDeg: 27, dist: 24, widthFrac: 1.4, aimX: 0.8, aimY: WORDS_Y, aimYFrac: 0.56 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#fbf3e7'], [0.6, '#f0e2ce'], [1, '#d8bd98']], 0, 0, 0, H);
@@ -699,68 +640,6 @@ const VARIANTS = [
vignette(ctx, W, H, 0.3);
},
},
{
id: 'brand-blue',
title: 'Бренд-градиент — доска парит, продуктовый вид',
palette: BRANDED,
font: 'brand',
cam: { elevDeg: 44, dist: 32, widthFrac: 0.8, fitHeightFrac: 0.84 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#173a86'], [0.5, '#2f6df6'], [1, '#1b4bb6']], 0, 0, W, H);
radialGlow(ctx, W, H, W * 0.5, H * 0.95, W * 0.62, 'rgba(255,255,255,0.22)', 'rgba(255,255,255,0)');
},
effects(ctx, W, H) {
farHaze(ctx, W, H, '23,58,134', 0.4, 0.45);
vignette(ctx, W, H, 0.28);
},
},
{
id: 'macro-low',
title: 'Макро — низкая камера, доска уходит за кадр',
palette: WARM,
font: 'brand',
tileHeight: 0.2,
cam: { elevDeg: 18, dist: 17, widthFrac: 1.9, aimX: 1.2, aimY: WORDS_Y, aimYFrac: 0.56 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#f2e6d3'], [0.4, '#dcc6a6'], [1, '#a87f52']], 0, 0, 0, H);
},
effects(ctx, W, H) {
farHaze(ctx, W, H, '242,230,211', 0.5, 0.92);
nearFade(ctx, W, H, '120,84,50', 0.8, 0.5);
vignette(ctx, W, H, 0.4);
},
},
{
id: 'flat-app',
title: 'Как в приложении — мягкий наклон, светлый фон',
palette: BRANDED,
font: 'system',
tileHeight: 0.13,
cam: { elevDeg: 58, dist: 34, widthFrac: 0.8, fitHeightFrac: 0.95 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#ffffff'], [1, '#e4eae6']], 0, 0, 0, H);
},
effects(ctx, W, H) {
vignette(ctx, W, H, 0.14);
},
},
{
id: 'night-wordmark',
title: 'Ночь с подписью — доска справа, слева название',
palette: NIGHT,
font: 'brand',
cam: { elevDeg: 29, dist: 25, widthFrac: 1.05, aimY: WORDS_Y, aimX: 0.8, aimXFrac: 0.64, aimYFrac: 0.56 },
background(ctx, W, H) {
linear(ctx, W, H, [[0, '#0a1210'], [0.55, '#14211c'], [1, '#060b09']], 0, 0, W, H);
radialGlow(ctx, W, H, W * 0.72, H * 0.55, W * 0.45, 'rgba(255,209,140,0.22)', 'rgba(255,209,140,0)');
},
effects(ctx, W, H) {
farHaze(ctx, W, H, '10,18,16', 0.46, 0.9);
nearFade(ctx, W, H, '6,11,9', 0.84, 0.7);
vignette(ctx, W, H, 0.5);
},
caption: { text: 'Эрудит', sub: 'erudit-game.ru', xFrac: 0.055, yFrac: 0.44, color: '#f2d9a0' },
},
];
// -------------------------------------------------------------------------- VK guides
@@ -809,48 +688,12 @@ function render(v, grid) {
return out;
}
/** contactSheet stacks the rendered variants two-up with their titles, so the whole set can
* be compared in one file. */
function contactSheet(rendered) {
const cols = 2;
const cw = OUT_W / 2;
const ch = OUT_H / 2;
const label = 44;
const rows = Math.ceil(rendered.length / cols);
const cv = new Canvas(cols * cw, rows * (ch + label));
const ctx = cv.getContext('2d');
ctx.fillStyle = '#14181f';
ctx.fillRect(0, 0, cv.width, cv.height);
ctx.imageSmoothingQuality = 'high';
rendered.forEach(({ canvas, v }, i) => {
const x = (i % cols) * cw;
const y = Math.floor(i / cols) * (ch + label);
ctx.fillStyle = '#e7eaf0';
ctx.font = '600 17px DejaVu Sans';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${i + 1}. ${v.id}${v.title}`, x + 12, y + label / 2);
ctx.drawImage(canvas, x, y + label, cw, ch);
});
return cv;
}
const grid = buildBoard();
const want = process.argv.slice(2);
const outDir = join(DIR, 'preview');
mkdirSync(outDir, { recursive: true });
const rendered = [];
for (const v of VARIANTS) {
if (want.length && !want.includes(v.id)) continue;
const started = Date.now();
const file = join(outDir, `cover-${v.id}.png`);
const canvas = render(v, grid);
canvas.toFileSync(file);
rendered.push({ canvas, v });
const file = join(DIR, `cover-${v.id}.png`);
render(v, grid).toFileSync(file);
console.log(`wrote ${file} (${Date.now() - started} ms) — ${v.title}`);
}
if (rendered.length > 1) {
const sheet = join(outDir, 'contact-sheet.png');
contactSheet(rendered).toFileSync(sheet);
console.log(`wrote ${sheet}`);
}

Before

Width:  |  Height:  |  Size: 447 KiB

After

Width:  |  Height:  |  Size: 447 KiB

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB