Zum Inhalt

scripts/render-readme-images.js

// Renders the README showcase images (assets/hero.png + gallery-*.png) from
// the ACTUAL built-in presets (perlin-presets.js) through the full render
// pipeline (perlin.js generatePerlinImage — domain warp, layer 2, flow,
// posterize and per-channel noise all supported). PNG encoding is pure Node
// (zlib + crc32), no external dependencies.
//
//   node scripts/render-readme-images.js
//
// Writes into assets/ — overwrites existing PNGs.
"use strict";

const fs = require("fs");
const zlib = require("zlib");
const path = require("path");
const { spawnSync } = require("child_process");

// perlin.js + perlin-presets.js export via window.* — build the sandbox as in
// AGENTS.md so the renderer uses the real presets with no duplicated data.
const REPO = path.resolve(__dirname, "..");
global.window = {};
require(path.join(REPO, "perlin.js"));
require(path.join(REPO, "perlin-presets.js"));
const { generatePerlinImage } = global.window.MTPerlin;
const { BUILTIN_PRESETS, resolveParams } = global.window.MTPerlinPresets;

// Headless 2D-context shim: generatePerlinImage only needs createImageData +
// putImageData — no canvas, no deps (same trick as scripts/smoke-test.js).
// Returns a packed RGB buffer (alpha dropped; presets don't use the mask).
function renderRGB(params) {
  const ctx = {
    createImageData: (w, h) => ({ width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }),
    _img: null,
    putImageData(img) { this._img = img; },
  };
  generatePerlinImage(params, ctx);
  const rgba = ctx._img.data;
  const rgb = Buffer.alloc(params.width * params.height * 3);
  for (let i = 0, j = 0; i < rgba.length; i += 4) {
    rgb[j++] = rgba[i]; rgb[j++] = rgba[i + 1]; rgb[j++] = rgba[i + 2];
  }
  return rgb;
}

// A single still frame from a named preset at a chosen loop position + size.
function presetFrame(name, width, height, loopProgress) {
  const preset = BUILTIN_PRESETS[name];
  if (!preset) throw new Error("unknown preset: " + name);
  return renderRGB(resolveParams({ ...preset, width, height, loop: true, loopProgress }));
}

// ---------- PNG encoder (pure Node) ----------
// CRC32 table per the PNG spec (Annex D, IEEE 802.3 polynomial).
const CRC_TABLE = (() => {
  const t = new Uint32Array(256);
  for (let n = 0; n < 256; n++) {
    let c = n;
    for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
    t[n] = c >>> 0;
  }
  return t;
})();
function crc32(buf) {
  let c = 0xffffffff;
  for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
  return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
  const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
  const typeBuf = Buffer.from(type, "ascii");
  const crc = Buffer.alloc(4);
  crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
  return Buffer.concat([len, typeBuf, data, crc]);
}
function encodePng(width, height, rgb) {
  // IHDR: width, height, bit depth=8, colour type=2 (RGB), no interlace.
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(width, 0);
  ihdr.writeUInt32BE(height, 4);
  ihdr.writeUInt8(8, 8);
  ihdr.writeUInt8(2, 9);
  ihdr.writeUInt8(0, 10);
  ihdr.writeUInt8(0, 11);
  ihdr.writeUInt8(0, 12);

  // Prefix each scanline with filter byte 0 (None).
  const stride = width * 3;
  const filtered = Buffer.alloc(height * (stride + 1));
  for (let y = 0; y < height; y++) {
    filtered[y * (stride + 1)] = 0;
    rgb.copy(filtered, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
  }
  const idat = zlib.deflateSync(filtered, { level: 9 });

  const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
  return Buffer.concat([
    signature,
    chunk("IHDR", ihdr),
    chunk("IDAT", idat),
    chunk("IEND", Buffer.alloc(0)),
  ]);
}

// ---------- Render jobs ----------
// Hero is the iconic Lava fire; the gallery shows the range of colour modes /
// features: HSV (aurora), per-channel RGB (nebula), domain warp (marble),
// flow field (flow), Layer 2 composite, and posterize (terrain).
// Gallery: 560 px = 2x the 280 px README cell (readme_lint assumes dpr 2), the
// largest size at which the high-entropy presets (flow, nebula) still fit the
// 400 KB PNG budget (readme-spec images.budget.png_kb) after oxipng. Hero:
// 1200x630 is the og:image size (index.html) and stays there, so at ~560 KB it
// is a known budget exception. Don't raise any size without re-measuring.
const ASSETS = path.join(REPO, "assets");
const JOBS = [
  { out: "hero.png",              preset: "lava",      width: 1200, height: 630, loopProgress: 0.42 },
  { out: "gallery-aurora.png",    preset: "aurora",    width: 560,  height: 373, loopProgress: 0.27 },
  { out: "gallery-nebula.png",    preset: "nebula",    width: 560,  height: 373, loopProgress: 0.55 },
  { out: "gallery-marble.png",    preset: "marble",    width: 560,  height: 373, loopProgress: 0.33 },
  { out: "gallery-flow.png",      preset: "flow",      width: 560,  height: 373, loopProgress: 0.18 },
  { out: "gallery-composite.png", preset: "composite", width: 560,  height: 373, loopProgress: 0.60 },
  { out: "gallery-terrain.png",   preset: "terrain",   width: 560,  height: 373, loopProgress: 0.50 },
];

if (!fs.existsSync(ASSETS)) {
  console.error("assets/ not found:", ASSETS);
  process.exit(1);
}

// Lossless recompression. The hand-rolled encoder above is deliberately
// simple (zlib only), so oxipng saves 30-50 % on top without touching a pixel.
// Optional: without it the images are valid but may exceed the PNG budget.
function optimize(file) {
  const r = spawnSync("oxipng", ["-o", "max", "-Z", "--strip", "all", "-q", file]);
  if (r.error && r.error.code === "ENOENT") {
    console.warn("  ! oxipng not on PATH (brew install oxipng) — image left unoptimized");
    return false;
  }
  return r.status === 0;
}

for (const job of JOBS) {
  const t0 = Date.now();
  const rgb = presetFrame(job.preset, job.width, job.height, job.loopProgress);
  const png = encodePng(job.width, job.height, rgb);
  const file = path.join(ASSETS, job.out);
  fs.writeFileSync(file, png);
  optimize(file);
  const kb = (fs.statSync(file).size / 1024).toFixed(1);
  console.log(`✓ ${job.out.padEnd(24)} ${job.preset.padEnd(10)} ${job.width}×${job.height}  ${kb} KB  ${Date.now() - t0} ms`);
}
console.log("all images written →", ASSETS);