Watercolor data art
skill · published by agentleFS · version 1 · approved by frdchang@gmail.com on 2026-10-12
Paint generative watercolor art from a chart's own data, after Tyler Hobbs' published recipe, on paper, transparent or dark.
Mountscripts/render.mjs
// render.mjs DATA.json OUT.png [--style grid|rothko|squares|matrix] [--seed 20261010] [--size 2400x840]
// [--palette kind|earth|sea|dusk|ink|"#a33a2c,#4f7392,..."]
// [--ghosts running|global|none] [--cell 26] [--max-scale N]
// [--background paper|transparent|dark]
//
// --background paper (default) is warm paper with grain. transparent leaves the canvas clear and
// dark paints a flat #111 ground; both skip the grain and lift the pigments so they read on a
// dark page. All three give the same squares for the same seed, so a pair lines up.
//
// --max-scale N caps px per unit at N, sizing by the stack alone (the older rule). Without it the
// picture is fitted to the canvas, ghosts included. Images made before the auto-fit need it:
// grid --max-scale 54, rothko --max-scale 50.
//
// Paints stacked or matrix data as watercolor in headless Chromium and saves the canvas pixels
// (not a screenshot) as PNG. Same data + options gives the same picture on the same Chromium
// build; another GPU or Skia build can differ in antialiasing.
//
// Playwright is looked up in this order:
// 1. PLAYWRIGHT_MODULE: a package name or a path to an installed package folder
// 2. "playwright", then "@playwright/test", resolved from the current working directory
// (so `npm i playwright` in the folder you run from works)
// 3. the same names in each NODE_PATH folder, then next to this script
// Chromium: CHROMIUM_PATH if set (any installed Chromium or Chrome); else Playwright's own build;
// else any Chromium Playwright downloaded earlier (PLAYWRIGHT_BROWSERS_PATH, ~/.cache/ms-playwright).
// A plain `npm i playwright` installs the newest version, which may want a Chromium build you do
// not have: pin the version that matches your browsers (e.g. npm i playwright@1.56.1) or set
// CHROMIUM_PATH. Pixel-identical output needs the same Chromium build.
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function loadPlaywright() {
const here = path.dirname(fileURLToPath(import.meta.url));
const bases = [process.cwd(), ...(process.env.NODE_PATH || "").split(path.delimiter).filter(Boolean), here];
const names = process.env.PLAYWRIGHT_MODULE ? [process.env.PLAYWRIGHT_MODULE] : ["playwright", "@playwright/test"];
const tried = [];
for (const base of bases) {
const req = createRequire(path.join(path.resolve(base), "noop.js"));
for (const n of names) {
try { return req(n); } catch { tried.push(`${n} from ${base}`); }
}
}
console.error("Cannot find Playwright. Tried:\n " + tried.join("\n ") +
"\nFix: run `npm i playwright && npx playwright install chromium` in the folder you run from," +
"\nor set PLAYWRIGHT_MODULE=/path/to/node_modules/playwright (and CHROMIUM_PATH=/path/to/chrome if needed).");
process.exit(1);
}
// Chromium builds Playwright has downloaded before, newest first: used when the installed
// Playwright wants a build that is not there (e.g. a newer `npm i playwright` than the browsers).
function installedChromiums() {
const roots = [process.env.PLAYWRIGHT_BROWSERS_PATH, path.join(os.homedir(), ".cache", "ms-playwright"),
path.join(os.homedir(), "Library", "Caches", "ms-playwright")].filter(Boolean);
const rel = ["chrome-linux/chrome", "chrome-linux64/chrome", "chrome-mac/Chromium.app/Contents/MacOS/Chromium",
"chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium", "chrome-win/chrome.exe"];
const found = [];
for (const r of roots) {
let dirs = [];
try { dirs = fs.readdirSync(r).filter(d => /^chromium-\d+$/.test(d)); } catch { continue; }
dirs.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
for (const d of dirs) for (const x of rel) { const p = path.join(r, d, x); if (fs.existsSync(p)) found.push(p); }
}
return found;
}
async function launch(pw) {
if (process.env.CHROMIUM_PATH) return pw.chromium.launch({ executablePath: process.env.CHROMIUM_PATH });
try { return await pw.chromium.launch(); } catch (e) {
for (const p of installedChromiums()) {
try { const b = await pw.chromium.launch({ executablePath: p }); console.error(`note: Playwright's own Chromium is missing; using ${p}`); return b; } catch {}
}
console.error("Playwright found, but Chromium did not start:\n" + String(e.message).split("\n")[0] +
"\nFix: npx playwright install chromium (with the same Playwright version), or set CHROMIUM_PATH" +
"\nto any installed Chromium or Chrome binary.");
process.exit(1);
}
}
const args = process.argv.slice(2);
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 ? args.splice(i, 2)[1] : dflt; };
const style = opt("--style", undefined);
const seed = Number(opt("--seed", "20261010"));
const size = opt("--size", "2400x840").split("x").map(Number);
const palArg = opt("--palette", undefined);
const ghosts = opt("--ghosts", "running");
const cell = opt("--cell", undefined);
const maxScale = opt("--max-scale", undefined);
const background = opt("--background", "paper");
const [dataPath, out] = args;
const STYLES = ["grid", "rothko", "squares", "matrix"];
if (!dataPath || !out || (style && !STYLES.includes(style)) || (maxScale !== undefined && !(Number(maxScale) > 0)) || !["running", "global", "none"].includes(ghosts) || !["paper", "transparent", "dark"].includes(background) || size.some(n => !(n > 0))) {
console.error("usage: render.mjs DATA.json OUT.png [--style grid|rothko|squares|matrix] [--seed N] [--size WxH]\n" +
" [--palette kind|earth|sea|dusk|ink|#hex,#hex,...] [--ghosts running|global|none] [--cell PX] [--max-scale N]\n" +
" [--background paper|transparent|dark]");
process.exit(2);
}
let data;
try { data = JSON.parse(fs.readFileSync(dataPath, "utf8")); }
catch (e) { console.error(`cannot read ${dataPath}: ${e.message}`); process.exit(2); }
// check the input before starting a browser: every value a number or null, no ragged rows
function validate(d) {
const bad = [];
const isVal = x => x === null || (typeof x === "number" && Number.isFinite(x));
if (Array.isArray(d.matrix)) {
if (!d.matrix.length || !d.matrix.every(Array.isArray)) return ["matrix must be a list of rows (lists)"];
const w = d.matrix[0].length;
d.matrix.forEach((r, i) => { if (r.length !== w) bad.push(`matrix row ${i} has ${r.length} cells, row 0 has ${w}: rows must be the same length (use null for an empty cell)`); });
d.matrix.forEach((r, i) => r.forEach((x, j) => { if (!isVal(x)) bad.push(`matrix[${i}][${j}] is ${JSON.stringify(x)}, not a number or null`); }));
if (style && style !== "matrix") bad.push(`--style ${style} needs stacked data (groups or series), not a matrix`);
return bad;
}
const list = d.groups || d.series;
if (!Array.isArray(list) || !list.length) return ["the data needs a non-empty \"groups\" or \"series\" list (or a \"matrix\")"];
if (style === "matrix") bad.push("--style matrix needs {matrix: [[...]]}");
const n = Array.isArray(d.q) ? d.q.length : list[0] && Array.isArray(list[0].v) ? list[0].v.length : 0;
list.forEach((g, i) => {
const name = g && g.name ? `"${g.name}"` : `#${i}`;
if (!g || !Array.isArray(g.v)) { bad.push(`series ${name} has no "v" list`); return; }
if (g.v.length !== n) bad.push(`series ${name} has ${g.v.length} values, expected ${n}${Array.isArray(d.q) ? " (one per period in q)" : ""}`);
g.v.forEach((x, j) => { if (!isVal(x)) bad.push(`series ${name} value ${j} is ${JSON.stringify(x)}, not a number or null`); });
});
if (n < 1) bad.push("no values to paint");
return bad;
}
const problems = validate(data);
if (problems.length) { console.error(`${dataPath}: ` + problems.slice(0, 10).join("\n ")); process.exit(2); }
const palette = palArg === undefined ? undefined : palArg.includes(",") || palArg.startsWith("#") ? palArg.split(",") : palArg;
const opts = { style, seed, size, palette, ghosts, cell: cell ? Number(cell) : undefined, maxScale: maxScale ? Number(maxScale) : undefined, background };
const pw = loadPlaywright();
const browser = await launch(pw);
const page = await browser.newPage({ viewport: { width: size[0], height: size[1] } });
const failed = new Promise(res => page.on("pageerror", e => res(e.message)));
await page.addInitScript(([d, o]) => { window.DATA = d; window.OPTS = o; }, [data, opts]);
await page.goto(pathToFileURL(path.join(path.dirname(fileURLToPath(import.meta.url)), "art.html")).href);
const err = await Promise.race([failed, page.waitForFunction(() => document.title === "done", null, { timeout: 600000 }).then(() => null)]);
if (err) { console.error("page error:", err); await browser.close(); process.exit(1); }
const url = await page.evaluate(() => document.getElementById("a").toDataURL("image/png"));
fs.writeFileSync(out, Buffer.from(url.split(",")[1], "base64"));
await browser.close();
console.log(`wrote ${out} (${style || (data.matrix ? "matrix" : "grid")}, seed ${seed}, ${size.join("x")}${(style || "grid") === "grid" && !data.matrix ? `, ghosts ${ghosts}` : ""}${background !== "paper" ? `, ${background}` : ""})`);

