144 lines
7.6 KiB
JavaScript
144 lines
7.6 KiB
JavaScript
// Draws the canvas art for the built-in themes that use a `data:` image background and writes
|
|
// src/inject/themeArt.ts. The art is procedural (Canvas 2D in headless Chromium, encoded as WebP),
|
|
// so it is reproducible and carries no third-party imagery.
|
|
//
|
|
// node scripts/theme-art.mjs
|
|
//
|
|
// X's columns cover the middle of the window, so the interesting parts sit toward the edges.
|
|
import { writeFile } from 'node:fs/promises';
|
|
import { chromium } from 'playwright';
|
|
|
|
const W = 1920;
|
|
const H = 1080;
|
|
|
|
/** Runs in the page. Each painter draws one W x H scene. */
|
|
function paint(name, W, H) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = W; canvas.height = H;
|
|
const g = canvas.getContext('2d');
|
|
let seed = 7;
|
|
const rand = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; };
|
|
|
|
if (name === 'aero') {
|
|
// Sky: deep glossy blue to a pale horizon.
|
|
const sky = g.createLinearGradient(0, 0, 0, H);
|
|
sky.addColorStop(0, '#0f6fc6'); sky.addColorStop(0.45, '#4fb3ea'); sky.addColorStop(0.8, '#a9dff7'); sky.addColorStop(1, '#d4f1fc');
|
|
g.fillStyle = sky; g.fillRect(0, 0, W, H);
|
|
// Sun glare, top right.
|
|
const sun = g.createRadialGradient(W * 0.86, H * 0.08, 0, W * 0.86, H * 0.08, H * 0.7);
|
|
sun.addColorStop(0, 'rgba(255,255,255,.95)'); sun.addColorStop(0.18, 'rgba(255,255,255,.45)'); sun.addColorStop(1, 'rgba(255,255,255,0)');
|
|
g.fillStyle = sun; g.fillRect(0, 0, W, H);
|
|
// Soft clouds.
|
|
g.filter = 'blur(28px)';
|
|
for (const [x, y, w, h, a] of [[0.1, 0.2, 360, 90, 0.85], [0.18, 0.26, 260, 70, 0.7], [0.83, 0.34, 420, 100, 0.8], [0.93, 0.28, 240, 70, 0.7], [0.06, 0.5, 300, 70, 0.55], [0.9, 0.56, 340, 80, 0.5]]) {
|
|
g.fillStyle = `rgba(255,255,255,${a})`;
|
|
g.beginPath(); g.ellipse(W * x, H * y, w / 2, h / 2, 0, 0, Math.PI * 2); g.fill();
|
|
}
|
|
g.filter = 'none';
|
|
// Aurora ribbons: translucent white swooshes.
|
|
for (const [y0, amp, a, width] of [[0.62, 90, 0.35, 60], [0.66, 70, 0.22, 110], [0.58, 110, 0.18, 30]]) {
|
|
g.strokeStyle = `rgba(255,255,255,${a})`; g.lineWidth = width; g.lineCap = 'round';
|
|
g.beginPath(); g.moveTo(-100, H * y0);
|
|
g.bezierCurveTo(W * 0.3, H * y0 - amp * 2, W * 0.6, H * y0 + amp * 2, W + 100, H * y0 - amp);
|
|
g.stroke();
|
|
}
|
|
// Rolling glossy hills.
|
|
const hill = (y, c1, c2, lift) => {
|
|
const grad = g.createLinearGradient(0, H * y - lift, 0, H);
|
|
grad.addColorStop(0, c1); grad.addColorStop(1, c2);
|
|
g.fillStyle = grad; g.beginPath(); g.moveTo(0, H * y);
|
|
g.bezierCurveTo(W * 0.25, H * y - lift, W * 0.55, H * y + lift * 0.5, W, H * y - lift * 0.6);
|
|
g.lineTo(W, H); g.lineTo(0, H); g.closePath(); g.fill();
|
|
};
|
|
hill(0.8, '#b4ec6e', '#46a83a', 90);
|
|
hill(0.9, '#6fcf3a', '#1f7a24', 60);
|
|
// Glossy bubbles, mostly at the edges.
|
|
const bubble = (x, y, r) => {
|
|
const body = g.createRadialGradient(x - r * 0.3, y - r * 0.35, r * 0.1, x, y, r);
|
|
body.addColorStop(0, 'rgba(255,255,255,.55)'); body.addColorStop(0.55, 'rgba(190,235,255,.18)'); body.addColorStop(0.92, 'rgba(120,200,255,.28)'); body.addColorStop(1, 'rgba(255,255,255,.75)');
|
|
g.fillStyle = body; g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fill();
|
|
const spec = g.createLinearGradient(x, y - r, x, y);
|
|
spec.addColorStop(0, 'rgba(255,255,255,.9)'); spec.addColorStop(1, 'rgba(255,255,255,0)');
|
|
g.fillStyle = spec; g.beginPath(); g.ellipse(x, y - r * 0.5, r * 0.62, r * 0.38, 0, 0, Math.PI * 2); g.fill();
|
|
g.fillStyle = 'rgba(255,255,255,.35)'; g.beginPath(); g.arc(x + r * 0.45, y + r * 0.5, r * 0.12, 0, Math.PI * 2); g.fill();
|
|
};
|
|
for (let i = 0; i < 26; i++) {
|
|
const left = i % 2 === 0;
|
|
const x = left ? rand() * W * 0.2 : W * 0.8 + rand() * W * 0.2;
|
|
bubble(x, H * (0.12 + rand() * 0.72), 10 + rand() ** 2 * 70);
|
|
}
|
|
bubble(W * 0.1, H * 0.66, 120); bubble(W * 0.9, H * 0.44, 150); bubble(W * 0.94, H * 0.72, 60);
|
|
}
|
|
|
|
if (name === 'vapor') {
|
|
const horizon = H * 0.62;
|
|
const sky = g.createLinearGradient(0, 0, 0, horizon);
|
|
sky.addColorStop(0, '#0b0120'); sky.addColorStop(0.45, '#2a0a52'); sky.addColorStop(0.8, '#7b1a7e'); sky.addColorStop(1, '#ff5fb0');
|
|
g.fillStyle = sky; g.fillRect(0, 0, W, horizon);
|
|
// Stars.
|
|
for (let i = 0; i < 260; i++) {
|
|
g.fillStyle = `rgba(255,255,255,${0.2 + rand() * 0.7})`;
|
|
const s = rand() < 0.1 ? 2 : 1;
|
|
g.fillRect(rand() * W, rand() * horizon * 0.75, s, s);
|
|
}
|
|
// Sliced sunset sun on the right.
|
|
const sx = W * 0.86, sy = horizon - 120, r = 240;
|
|
g.save();
|
|
g.beginPath(); g.arc(sx, sy, r, 0, Math.PI * 2); g.clip();
|
|
const sun = g.createLinearGradient(0, sy - r, 0, sy + r * 0.4);
|
|
sun.addColorStop(0, '#fff27a'); sun.addColorStop(0.5, '#ff9a5a'); sun.addColorStop(1, '#ff2d95');
|
|
g.fillStyle = sun; g.fillRect(sx - r, sy - r, r * 2, r * 2);
|
|
g.restore();
|
|
g.globalCompositeOperation = 'destination-out';
|
|
for (let i = 0; i < 7; i++) { const y = sy - 30 + i * 24; g.fillRect(sx - r, y, r * 2, 3 + i * 2.4); }
|
|
g.globalCompositeOperation = 'destination-over';
|
|
g.fillStyle = sky; g.fillRect(0, 0, W, horizon);
|
|
g.globalCompositeOperation = 'source-over';
|
|
// Sun halo.
|
|
const halo = g.createRadialGradient(sx, sy, r * 0.9, sx, sy, r * 1.8);
|
|
halo.addColorStop(0, 'rgba(255,95,176,.35)'); halo.addColorStop(1, 'rgba(255,95,176,0)');
|
|
g.fillStyle = halo; g.fillRect(0, 0, W, horizon);
|
|
// Wireframe mountains on the left.
|
|
g.fillStyle = '#12002b'; g.strokeStyle = 'rgba(1,205,254,.75)'; g.lineWidth = 2;
|
|
g.beginPath(); g.moveTo(0, horizon);
|
|
for (const [x, y] of [[0.03, 0.4], [0.08, 0.5], [0.14, 0.33], [0.2, 0.47], [0.26, 0.4], [0.33, 0.62]]) g.lineTo(W * x, H * y);
|
|
g.closePath(); g.fill(); g.stroke();
|
|
// Ground and perspective grid.
|
|
const ground = g.createLinearGradient(0, horizon, 0, H);
|
|
ground.addColorStop(0, '#2a0145'); ground.addColorStop(1, '#07000f');
|
|
g.fillStyle = ground; g.fillRect(0, horizon, W, H - horizon);
|
|
g.shadowColor = '#ff2bd6'; g.shadowBlur = 10; g.strokeStyle = 'rgba(255,60,220,.85)'; g.lineWidth = 2;
|
|
for (let k = 1; k < 22; k++) {
|
|
const y = horizon + (H - horizon) * (k / 21) ** 2.2;
|
|
g.beginPath(); g.moveTo(0, y); g.lineTo(W, y); g.stroke();
|
|
}
|
|
const vx = W * 0.5;
|
|
for (let k = -30; k <= 30; k++) {
|
|
g.beginPath(); g.moveTo(vx + k * 18, horizon); g.lineTo(vx + k * 260, H); g.stroke();
|
|
}
|
|
g.shadowBlur = 0;
|
|
// Horizon glow.
|
|
const line = g.createLinearGradient(0, horizon - 30, 0, horizon + 30);
|
|
line.addColorStop(0, 'rgba(255,95,176,0)'); line.addColorStop(0.5, 'rgba(255,170,220,.9)'); line.addColorStop(1, 'rgba(255,95,176,0)');
|
|
g.fillStyle = line; g.fillRect(0, horizon - 30, W, 60);
|
|
}
|
|
return canvas.toDataURL('image/webp', 0.8);
|
|
}
|
|
|
|
const browser = await chromium.launch();
|
|
try {
|
|
const page = await browser.newPage();
|
|
const art = {};
|
|
for (const name of ['aero', 'vapor']) art[name] = await page.evaluate(([n, w, h]) => (0, eval)(`(${n.fn})`)(n.name, w, h), [{ fn: paint.toString(), name }, W, H]);
|
|
const out = [
|
|
'// Generated by scripts/theme-art.mjs (procedural Canvas art, WebP). Do not edit by hand.',
|
|
'// Used as `data:` image backgrounds by the built-in themes in themeSpec.ts.',
|
|
'',
|
|
...Object.entries(art).map(([name, url]) => `export const ART_${name.toUpperCase()} = '${url}';`),
|
|
'',
|
|
].join('\n');
|
|
await writeFile(new URL('../src/inject/themeArt.ts', import.meta.url), out);
|
|
for (const [name, url] of Object.entries(art)) console.log(name, `${Math.round(url.length / 1024)} KB`);
|
|
} finally {
|
|
await browser.close();
|
|
}
|