press.ts

10.9 kB · typescript · 275 lines

1import { createHash } from "node:crypto";2import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";3import { availableParallelism } from "node:os";4import { dirname, join, relative, resolve } from "node:path";5import sharp from "sharp";6import { ink, raster, svg, type Ink, type Palette, type Pen, type Pixels } from "mrlyjs/view";78/* WHERE */910const USAGE = "usage: bun press.ts [name ...] [--svg] [--png]";11const HERE = import.meta.dir;12const DESK = resolve(HERE, "../..");13const MRLYJS = resolve(HERE, "../pkgs/mrlyjs");14const DATA_DIR = join(DESK, "data", relative(DESK, HERE));15const STORE = join(DATA_DIR, "store");16const LOCK = join(DATA_DIR, "figures.lock");17const BENCH = join(DATA_DIR, "press.txt");1819/* RECIPE */2021const FIGURE = /^(site|research|paper|wiki|demo|blog)-[a-z0-9-]+\.ts$/;22const KIT = /^(pkg\/.+\.(js|wasm)|(view\/)?[^/]+\.js)$/;23const THEMES = ["dark", "light"] as const;24const SIZE = [1024, 1024];25const BUDGET = 5000;26const WIDTH = 1024;27const WEBP = { quality: 90, effort: 6 };28const PNG = { compressionLevel: 9 };2930type Theme = (typeof THEMES)[number];31type Flags = { svg: boolean; png: boolean };32type Job = { name: string; file: string; dir: string; missing: string[]; palettes: Record<Theme, Palette> };33type Row = { state: "drawn" | "cached" | "failed"; name: string; theme: string; draw?: number; encode?: number; extra?: number; note?: string };34type Done = { rows: Row[]; ms: number };35type Unit = { initSync: (options: { module: Uint8Array }) => unknown };36type Figure = { default: (pen: Pen, ink: Ink, t: number) => void; size?: number[]; still?: number; units?: Record<string, Unit> };3738const reason = (error: unknown) => (error instanceof Error ? error.message : String(error)).split("\n")[0];3940/* KEY */4142const sha = (data: string | Uint8Array) => createHash("sha256").update(data).digest("hex");43const scanner = new Bun.Transpiler({ loader: "ts" });4445function digest(files: string[], root: string) {46  const lines = files.map((file) => `${sha(readFileSync(file))}  ${relative(root, file)}\n`);47  return sha(lines.sort().join(""));48}4950function kit() {51  const files = (readdirSync(MRLYJS, { recursive: true }) as string[]).filter((file) => KIT.test(file) && !file.endsWith(".test.js"));52  return digest(files.map((file) => join(MRLYJS, file)), MRLYJS);53}5455function sources(file: string, seen = new Set<string>()) {56  if (seen.has(file)) return seen;57  seen.add(file);58  if (!/\.[cm]?[jt]s$/.test(file)) return seen;59  for (const { path } of scanner.scanImports(readFileSync(file, "utf8"))) {60    if (path.startsWith(".")) sources(Bun.resolveSync(path, dirname(file)), seen);61  }62  return seen;63}6465const key = (base: string, name: string, file: string) => sha(`${base}\n${name}\n${digest([...sources(file)], HERE)}`);6667/* WORKER */6869const pixels = (p: Pixels) => sharp(p.colors, { raw: { width: p.shape[1], height: p.shape[0], channels: 4 } });7071async function clock(work: () => Promise<void>) {72  const start = performance.now();73  await work();74  return performance.now() - start;75}7677async function press(job: Job, ready: Set<string>) {78  const rows: Row[] = [];79  let theme = "-";80  try {81    const figure: Figure = await import(job.file);82    if (typeof figure.default !== "function") throw new Error("no default draw");83    for (const [unit, door] of Object.entries(figure.units ?? {})) {84      if (ready.has(unit)) continue;85      door.initSync({ module: readFileSync(join(MRLYJS, "pkg", unit, `mrlyjs_${unit}_bg.wasm`)) });86      ready.add(unit);87    }88    const [width, height] = figure.size ?? SIZE;89    const missing = new Set(job.missing);90    mkdirSync(job.dir, { recursive: true });91    const write = (file: string, bytes: Uint8Array | string) => {92      writeFileSync(join(job.dir, `${file}.tmp`), bytes);93      renameSync(join(job.dir, `${file}.tmp`), join(job.dir, file));94    };95    const draw = (pen: Pen, tint: Ink) => {96      postMessage("draw");97      const start = performance.now();98      figure.default(pen, tint, figure.still ?? 0);99      const ms = performance.now() - start;100      postMessage("drawn");101      if (ms > BUDGET) throw new Error(`draw took ${ms.toFixed(0)} ms, over ${BUDGET}`);102      return ms;103    };104    for (theme of THEMES) {105      const tint = ink(job.palettes[theme as Theme]);106      const row = { state: "drawn" as const, name: job.name, theme, draw: undefined as number | undefined, encode: 0, extra: 0 };107      const webp = `${job.name}-${theme}.webp`;108      const png = `${job.name}-${theme}.png`;109      const vector = `${job.name}-${theme}.svg`;110      const og = theme === "dark" && missing.has(`${job.name}.png`);111      if (missing.has(webp) || missing.has(png) || og) {112        const pen = raster(width, height, tint.ground);113        row.draw = draw(pen, tint);114        if (missing.has(webp)) {115          row.encode += await clock(async () => {116            const image = pixels(pen.pixels());117            write(webp, await (width > WIDTH ? image.resize({ width: WIDTH, kernel: "lanczos3" }) : image).webp(WEBP).toBuffer());118          });119        }120        if (og || missing.has(png)) {121          row[og ? "encode" : "extra"] += await clock(async () => {122            const bytes = await pixels(pen.pixels()).png(PNG).toBuffer();123            if (og) write(`${job.name}.png`, bytes);124            if (missing.has(png)) write(png, bytes);125          });126        }127      }128      if (missing.has(vector)) {129        row.extra += await clock(async () => {130          const pen = svg(width, height, tint.ground);131          draw(pen, tint);132          write(vector, await pen.text({ png: (p) => pixels(p).png().toBuffer() }));133        });134      }135      rows.push(row);136    }137  } catch (error) {138    rows.push({ state: "failed", name: job.name, theme, note: reason(error) });139  }140  return rows;141}142143function serve() {144  const ready = new Set<string>();145  self.onmessage = async ({ data }: MessageEvent<Job>) => {146    const start = performance.now();147    const rows = await press(data, ready);148    postMessage({ rows, ms: performance.now() - start });149  };150}151152/* CREW */153154function run(worker: Worker, job: Job) {155  return new Promise<Done | string>((done) => {156    let timer: ReturnType<typeof setTimeout>;157    const arm = () => {158      clearTimeout(timer);159      timer = setTimeout(() => done(`over ${BUDGET} ms`), BUDGET);160    };161    worker.onmessage = ({ data }: MessageEvent<"draw" | "drawn" | Done>) => {162      if (data === "draw") return arm();163      clearTimeout(timer);164      if (data !== "drawn") done(data);165    };166    worker.onerror = (event) => {167      clearTimeout(timer);168      done(reason(event.message || "the worker died"));169    };170    arm();171    worker.postMessage(job);172  });173}174175function crew(jobs: Job[], cores: number, note: (done: Done) => void) {176  let next = 0;177  const lane = async () => {178    let worker = new Worker(import.meta.url);179    while (next < jobs.length) {180      const job = jobs[next++];181      const done = await run(worker, job);182      if (typeof done !== "string") note(done);183      else {184        worker.terminate();185        worker = new Worker(import.meta.url);186        note({ rows: [{ state: "failed", name: job.name, theme: "-", note: done }], ms: 0 });187      }188    }189    worker.terminate();190  };191  return Promise.all(Array.from({ length: Math.min(cores, jobs.length) }, lane));192}193194/* MAIN */195196function args(argv: string[]) {197  const flags: Flags = { svg: false, png: false };198  const names = new Set<string>();199  for (const arg of argv) {200    if (arg === "--svg") flags.svg = true;201    else if (arg === "--png") flags.png = true;202    else if (arg.startsWith("-")) throw new Error(USAGE);203    else names.add(arg);204  }205  return { flags, names: [...names] };206}207208function outputs(name: string, flags: Flags) {209  const out = [`${name}-dark.webp`, `${name}-light.webp`, `${name}.png`];210  if (flags.svg) out.push(`${name}-dark.svg`, `${name}-light.svg`);211  if (flags.png) out.push(`${name}-dark.png`, `${name}-light.png`);212  return out;213}214215const ms = (value?: number) => (value === undefined ? "-" : value.toFixed(1)).padStart(8);216217function line(row: Row) {218  const extra = row.extra ? `  extra ${row.extra.toFixed(1)}` : "";219  return `${row.state.padEnd(6)}  ${ms(row.draw)}  ${ms(row.encode)}  ${row.name}  ${row.theme}${extra}${row.note ? `  ${row.note}` : ""}`;220}221222async function main() {223  const { flags, names } = args(process.argv.slice(2));224  const cores = availableParallelism();225  const all = readdirSync(HERE).filter((file) => FIGURE.test(file)).map((file) => file.slice(0, -3)).sort();226  const core = await import("mrlyjs/core");227  core.initSync({ module: readFileSync(join(MRLYJS, "pkg/core/mrlyjs_core_bg.wasm")) });228  const palettes: Record<Theme, Palette> = { dark: core.colors.DARK().toJSON(), light: core.colors.LIGHT().toJSON() };229  const base = sha(JSON.stringify([sha(readFileSync(import.meta.path)), kit(), palettes]));230  const rows: Row[] = [];231  const keys: Record<string, string> = {};232  const jobs: Job[] = [];233  let work = 0;234  const note = (row: Row) => {235    rows.push(row);236    console.log(line(row));237  };238  const wanted = names.length ? names : all;239  for (const name of wanted) {240    try {241      if (!all.includes(name)) throw new Error("no such figure");242      const file = join(HERE, `${name}.ts`);243      keys[name] = key(base, name, file);244      const dir = join(STORE, keys[name]);245      const missing = outputs(name, flags).filter((out) => !existsSync(join(dir, out)));246      if (missing.length) jobs.push({ name, file, dir, missing, palettes });247      else for (const theme of THEMES) note({ state: "cached", name, theme });248    } catch (error) {249      note({ state: "failed", name, theme: "-", note: reason(error) });250    }251  }252  await crew(jobs, cores, (done) => {253    work += done.ms;254    done.rows.forEach(note);255  });256  const named = (state: Row["state"]) => new Set(rows.filter((row) => row.state === state).map((row) => row.name));257  const failed = named("failed");258  const drawn = [...named("drawn")].filter((name) => !failed.has(name));259  const lock: Record<string, string> = names.length && existsSync(LOCK) ? JSON.parse(readFileSync(LOCK, "utf8")) : {};260  for (const [name, hash] of Object.entries(keys)) if (!failed.has(name)) lock[name] = hash;261  const held = new Set(Object.values(lock));262  if (!names.length && existsSync(STORE)) {263    for (const stale of readdirSync(STORE)) if (!held.has(stale)) rmSync(join(STORE, stale), { recursive: true });264  }265  const last = `${wanted.length} figures: ${drawn.length} drawn, ${named("cached").size} cached, ${failed.size} failed, wall ${performance.now().toFixed(0)} ms, work ${work.toFixed(0)} ms, ${cores} cores`;266  console.log(last);267  mkdirSync(DATA_DIR, { recursive: true });268  writeFileSync(LOCK, `${JSON.stringify(Object.fromEntries(Object.entries(lock).sort()), null, 2)}\n`);269  const sorted = [...rows].sort((a, b) => (a.name + a.theme < b.name + b.theme ? -1 : 1));270  writeFileSync(BENCH, `${[...sorted.map(line), last].join("\n")}\n`);271  process.exit(failed.size ? 1 : 0);272}273274if (!Bun.isMainThread) serve();275else if (import.meta.main) await main();