diff --git a/kidlisp/SCORE.md b/kidlisp/SCORE.md new file mode 100644 index 000000000..296fbadb3 --- /dev/null +++ b/kidlisp/SCORE.md @@ -0,0 +1,108 @@ +# KidLisp — Score + +The KidLisp "score": one place to track the language spec, the reference implementation, every conforming runtime in the monorepo, and the conformance corpus they're all graded against. + +> KidLisp is *a language*, not *a file*. `system/public/aesthetic.computer/lib/kidlisp.mjs` is the **reference implementation**; the **specification** is `KidLisp Decree '26`. Any runtime — JS, Common Lisp, Swift, WASM, Game Boy ROM — claims a conformance level against the Decree and runs the same corpus. + +## 1. Specification + +| Document | Path | Role | +|---|---|---| +| Decree '26 | `kidlisp/docs/core/kidlisp-decree-26.md` | Normative ABI + conformance levels | +| Decree (latest pointer) | `kidlisp/docs/core/kidlisp-decree.md` | Always points at the current stable decree | +| Language reference | `kidlisp/docs/core/language-reference.md` | Syntax + constructs | +| Complete API map | `kidlisp/COMPLETE_API_MAP.md` | All 118 built-ins, grouped | + +**Conformance levels** (Decree '26 §3): +- `Core` — parser, evaluator, lifecycle ABI, host shape +- `Render` — offscreen buffers, `page`/`paste`, alpha compositing +- `Audio` — `amp`/`mic` globals + ranges + +**Named profiles:** +- `RBP-26` — `$roz` Baseline Profile (Decree '26 §13.1). Minimum surface to run `$roz` correctly: `ink`/`line`/`circle`/`scroll`/`spin`/`zoom`/`contrast`/`?`/`1s...`/`2s...`/`0.5s`/`fade:…`, magic vars `w`/`h`/`w/2`/`h/2`. + +Canonical claim format: `KidLisp Decree '26: Core + Render` (extend with `+ Audio` and/or `+ RBP-26` as supported). + +## 2. Reference Implementation + +| Path | Surface | +|---|---| +| `system/public/aesthetic.computer/lib/kidlisp.mjs` | The canonical evaluator — JS, runs in browser + Node + JSC | +| `system/netlify/functions/store-kidlisp.mjs` | Source storage / `$code` resolution / hit counting | +| `kidlisp/tools/` | `api-summary.mjs`, `source-tree.mjs`, etc. | + +The reference impl is **load-bearing for deployment** (web runtime, service worker cache, `disk.mjs` imports, session server). Its path is frozen; do not move it. + +## 3. Runtime Registry + +Every implementation in the monorepo, with claimed conformance level and current status. **Update this row when you change a runtime's surface.** + +| Runtime | Path | Decree claim | Status | Notes | +|---|---|---|---|---| +| **JS** (reference) | `system/public/aesthetic.computer/lib/kidlisp.mjs` | `'26: Core + Render + Audio` | shipping | Canonical; spec defers to behavior here when ambiguous | +| **Common Lisp** (AC Native) | `fedac/native/cl/kidlisp-*.lisp` | `'26: Core + Render` (target) | in progress | Tree-walker, DRM/KMS framebuffer; replacing QuickJS path | +| **Swift** (Menuband) | `slab/menuband/` | `'26: Core + Render` (planned) | not started | This document's motivating port; Metal blit + CPU framebuffer | +| **WASM** | `kidlisp-wasm/` | unclaimed | experimental | Compiler approach (`compiler.mjs`) | +| **Playdate** | `kidlisp-playdate/` | unclaimed | experimental | C runtime for Panic Playdate | +| **Game Boy** | `kidlisp-gameboy/` | unclaimed | experimental | GBDK C + asm | +| **N64** | `kidlisp-n64/` | unclaimed | experimental | Bare-metal asm exploration | +| **CLI** | `kidlisp-cli/` | host-runner | shipping | Public `kidlisp` CLI | +| **Sidecar** | `kidlisp-sidecar/` | host-service | shipping | Clojure service | +| **VS Code syntax** | `vscode-extension/kidlisp-syntax.ts` | tooling | shipping | Editor highlighting only | +| **kidlisp.com** | `kidlisp.com/` | site | shipping | Landing page | +| **Knowledge base** | `kidlisp-knowledge/` | docs | shipping | LLM-oriented documentation aggregator | +| **Analysis tools** | `kidlisp-tools/`, `kidlisp/tools/` | tooling | shipping | Probe + source-tree (two locations — see Open Questions) | + +## 4. Conformance Corpus + +The corpus is **the top KidLisp pieces by live hit count**, pulled from production. Any new runtime is expected to render these pixel-comparably against the reference implementation. + +Refresh command: `curl -s "https://aesthetic.computer/api/store-kidlisp?recent=true&limit=10&sort=hits"`. + +### Top 10 (refreshed 2026-05-25) + +| # | Code | Hits | Chars | Source / feature footprint | +|---|------|-----:|------:|---| +| 1 | `$bop` | 14,708 | 25 | `purple, ink, line, blur 5` — bare-color wipe, bare commands, blur | +| 2 | `$pie` | 10,363 | 153 | `(fps 24)`, timing wipe, magic vars, `scroll frame frame` | +| 3 | `$roz` | 9,106 | 239 | `fade:` gradient, `1s...`/`2s...`/`0.5s`, `?`, spin/zoom/contrast/scroll/circle → **RBP-26 reference** | +| 4 | `$4xa` | 8,330 | 4 | `blue` — bare color = implicit wipe | +| 5 | `$ceo` | 8,167 | 82 | `coat fade:…:frame` animated gradient + zoom | +| 6 | `$cow` | 7,819 | 52 | `($39i 0 0 w h 128)` `($r2f …)` — `$code` embeds (recursive eval) | +| 7 | `$4bb` | 5,629 | 215 | `bake`/`burn`, `ink … erase`, scroll vectors, blur | +| 8 | `$wib` | 5,424 | 11 | `(wipe blue)` | +| 9 | `$39i` | 5,332 | 275 | flood, circle, timed zoom/blur/contrast, multi-statement | +| 10 | `$nsh` | 5,326 | 7 | `kidlisp` (bare identifier) | + +### Suggested phasing for a new port + +1. **$bop · $wib · $4xa** — bare color → wipe, bare commands, `(wipe color)`, `blur N` +2. **$pie · $39i** — `(fps N)`, timing tokens, magic vars (`w`/`h`/`width`/`height`/`frame`), `scroll`, `flood`, `circle`, `zoom`, `contrast` +3. **$roz · $ceo** — `fade:` gradients with `:frame` animation, `spin`, `coat`, `?`/`...` cycle → **claims `RBP-26`** +4. **$cow** — `$code` embed (recursive sub-region eval + network/cache fetch) +5. **$4bb** — `bake`/`burn` (offscreen page semantics, Decree '26 §6) + +A port hitting phase 3 can publish `KidLisp Decree '26: Core + Render + RBP-26`. + +## 5. Adding a New Runtime + +1. Pick a path. Sibling-of-monorepo (`kidlisp-foo/`) for hardware/platform ports; embedded inside a host app (`slab/menuband/`, `fedac/native/cl/`) for ports tied to a specific runtime. +2. Add a row to §3 above with `claim = unclaimed`, `status = not started`. +3. Build through the §4 phases. After each phase, render the corpus and diff against reference frames. +4. When all `RBP-26` tokens render correctly, update the row to `'26: Core + Render + RBP-26`. +5. Conformance test artifacts (frame PNGs, diff reports) should land under `kidlisp/conformance//`. + +## 6. Open Questions + +- **`kidlisp/tools/` vs `kidlisp-tools/`** — two tool homes; the sibling should probably absorb the sub-dir or vice versa. Not urgent, but document the decision when made. +- **Physical reorg** — should the reference impl be re-homed under `kidlisp/` and re-exported from `system/public/aesthetic.computer/lib/`? Conceptually cleaner, but the blast radius (service worker cache keys, bundler resolution, WebSocket module loader prefetch, every `disk.mjs` import path) hasn't earned its cost. **Decision: keep `kidlisp.mjs` where it is; `kidlisp/` is the *house* (spec, corpus, docs, registry), not the *runtime location*.** +- **`KDL-26` test suite** — the Decree §10 reserves this name for the formal conformance suite. Land the corpus in §4 here, then graduate it to `KDL-26` once stable. +- **Sibling consolidation** — `kidlisp-knowledge/` and `kidlisp/docs/` overlap; resolve before they drift further. + +## 7. Pointers + +- Decree '26: [`docs/core/kidlisp-decree-26.md`](docs/core/kidlisp-decree-26.md) +- Language reference: [`docs/core/language-reference.md`](docs/core/language-reference.md) +- API map: [`COMPLETE_API_MAP.md`](COMPLETE_API_MAP.md) +- Directory map: [`STRUCTURE.md`](STRUCTURE.md) +- Top-level project score: [`../SCORE.md`](../SCORE.md) diff --git a/kidlisp/STRUCTURE.md b/kidlisp/STRUCTURE.md index a93267af0..ffa0403ab 100644 --- a/kidlisp/STRUCTURE.md +++ b/kidlisp/STRUCTURE.md @@ -1,70 +1,74 @@ # KidLisp Directory Structure -This directory contains all KidLisp-related documentation, tools, examples, and reports. +This is the navigation map for everything KidLisp-related in the monorepo. For the **runtime registry, spec pointers, and conformance corpus**, see [`SCORE.md`](SCORE.md). -## 📁 Directory Organization +## 🏠 The KidLisp House (this directory) + +`kidlisp/` is the *house* — spec, docs, corpus, tooling, registry. It is **not** where the reference evaluator lives (see §"Reference Implementation" below). ``` kidlisp/ -├── README.md # 📖 Main KidLisp documentation & overview -├── STRUCTURE.md # 🗺️ This navigation guide -├── COMPLETE_API_MAP.md # 🎯 Complete function reference (118 functions) -├── docs/ # 📚 Detailed documentation -│ ├── README.md # Documentation directory overview -│ ├── suck-complete-technical-guide.md # 🌪️ Complete suck function documentation -│ ├── suck-function-fixed.md # Historical suck implementation notes -│ ├── suck-redesign-notes.md # Design evolution notes -│ ├── core/ # Core language features -│ │ └── language-reference.md # Language syntax & constructs -│ ├── features/ # Feature-specific docs -│ │ ├── embedding-system.md # Code embedding system -│ │ ├── feed-system.md # Feed integration -│ │ ├── suck-lossless-implementation.md # Suck algorithm details -│ │ └── transformation-functions.md # All transformation docs -│ ├── implementation/ # Implementation guides -│ │ ├── blur-buffer-issues.md # Blur system implementation -│ │ ├── embedding-fixes.md # Embedding system fixes -│ │ ├── make-integration.md # Build system integration -│ │ ├── singleton-refactor.md # Singleton pattern refactor -│ │ ├── todo-fixes.md # TODO list fixes -│ │ └── unified-kidlisp-execution.md # Execution system -│ ├── integration/ # External integrations -│ │ └── x-ff1-art-computer.md # Feral File integration -│ └── reports/ # Technical analysis -│ ├── fa2-contract-spec.md # FA2 contract specification -│ └── feral-file-integration.md # Feral File technical details -├── examples/ # 📋 Code examples & tutorials -│ └── README.md # Examples directory overview -├── reports/ # 📊 Technical reports & analysis -│ ├── README.md # Reports directory overview -│ ├── analysis-report.md # Architecture analysis -│ ├── comparison-metrics.md # Performance comparisons -│ ├── implementation-guide.md # Implementation guidelines -│ ├── zoom-vs-suck-implementation-report.md # Transformation comparison -│ └── tinylisp/ # TinyLisp comparison research -│ ├── README.md # TinyLisp research overview -│ ├── [... full tinylisp research archive ...] -└── tools/ # 🔧 Development utilities - ├── README.md # Tools directory overview - ├── api-summary.mjs # 🎯 API analysis tool (118 functions) - ├── get-source.mjs # Source extraction utility - └── source-tree.mjs # Codebase analysis tool +├── SCORE.md # 🎯 Runtime registry + spec pointers + conformance corpus +├── STRUCTURE.md # 🗺️ This file +├── README.md # 📖 Language overview & user-facing intro +├── COMPLETE_API_MAP.md # 📋 All 118 built-ins, grouped +├── CHAOS-MODE.md # 🌀 Chaos-mode notes +├── PERFORMANCE-ANALYSIS.md # ⚡ Perf benchmarks +├── CITATION.cff # 📚 Citation metadata +├── docs/ +│ ├── core/ +│ │ ├── kidlisp-decree.md # → latest-stable pointer +│ │ ├── kidlisp-decree-26.md # 📜 Decree '26 (current stable spec) +│ │ └── language-reference.md # Syntax + constructs +│ ├── features/ # Per-feature deep dives (embed, fade, suck, transforms…) +│ ├── functions/ # Per-function references +│ ├── implementation/ # Implementation notes (not normative) +│ ├── integration/ # External integrations (Feral File, etc.) +│ └── reports/ # Technical analyses +├── dictionary/ # Token dictionary work +├── examples/ # Working KidLisp pieces +├── reports/ # Architecture / comparison reports +└── tools/ # Source-fetching + API-summary scripts (Node) ``` -## 📚 What's Where +## 🏗️ Reference Implementation (lives outside this directory) -- **README.md**: Start here! Main language documentation and API reference -- **docs/**: Function-specific documentation, design notes, and knowledge base -- **reports/**: Technical analysis, architecture decisions, implementation comparisons -- **examples/**: Working KidLisp code examples and demos -- **tools/**: Development utilities and helper scripts +The canonical evaluator is **not** under `kidlisp/`. It lives where the web runtime needs it: -## 🎯 Quick Navigation +| Path | What it is | +|---|---| +| `system/public/aesthetic.computer/lib/kidlisp.mjs` | Reference evaluator (118 built-ins, frozen path — load-bearing for deployment) | +| `system/netlify/functions/store-kidlisp.mjs` | Source storage / `$code` resolution / hit-count API | +| `system/public/aesthetic.computer/disks/*.lisp` | Built-in KidLisp pieces shipped with AC | + +Don't move these. The Decree (`docs/core/kidlisp-decree-26.md`) is the abstract spec; `kidlisp.mjs` is the behavioral definition. + +## 🛰️ Sibling Implementations (other runtimes in the monorepo) -- **Learning KidLisp**: Start with [README.md](README.md) -- **Function Documentation**: Check [docs/](docs/) for specific functions -- **Technical Details**: See [reports/](reports/) for implementation analysis -- **Code Examples**: Browse [examples/](examples/) for working programs -- **Development**: Use [tools/](tools/) for development utilities +Sibling directories at the monorepo root, each implementing or supporting KidLisp on a different host: + +| Path | Purpose | +|---|---| +| `fedac/native/cl/kidlisp-*.lisp` | Common Lisp port (AC Native OS, runs on bare-metal ThinkPads) | +| `slab/menuband/` | Swift port for Menuband (planned — see SCORE.md) | +| `kidlisp-wasm/` | WASM compiler | +| `kidlisp-playdate/` | Panic Playdate runtime (C) | +| `kidlisp-gameboy/` | Game Boy ROM toolchain (GBDK + asm) | +| `kidlisp-n64/` | Nintendo 64 exploration (bare-metal asm) | +| `kidlisp-cli/` | Public `kidlisp` CLI | +| `kidlisp-sidecar/` | Clojure service | +| `kidlisp-tools/` | Probe + source-tree utilities (separate from `kidlisp/tools/` — see SCORE.md §6) | +| `kidlisp-knowledge/` | LLM-oriented knowledge base aggregator | +| `kidlisp.com/` | Landing page | +| `vscode-extension/kidlisp-syntax.ts` | Editor syntax highlighting | + +For status, claimed conformance level, and per-runtime notes: see [`SCORE.md`](SCORE.md) §3. + +## 🎯 Quick Navigation -This organization keeps all KidLisp-related materials together while maintaining clear separation between user documentation, technical reports, and development resources. +- **What is KidLisp?** → [`README.md`](README.md) +- **Adding a new runtime / checking conformance** → [`SCORE.md`](SCORE.md) +- **Reading the spec** → [`docs/core/kidlisp-decree-26.md`](docs/core/kidlisp-decree-26.md) +- **Function reference** → [`COMPLETE_API_MAP.md`](COMPLETE_API_MAP.md) or [`docs/functions/`](docs/functions/) +- **Working examples** → [`examples/`](examples/) +- **Dev tooling** → [`tools/`](tools/) (this dir) or `kidlisp-tools/` (sibling) diff --git a/kidlisp/tools/gen-spinners.mjs b/kidlisp/tools/gen-spinners.mjs new file mode 100644 index 000000000..ce7f82b14 --- /dev/null +++ b/kidlisp/tools/gen-spinners.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// gen-spinners.mjs — generate a catalog of N spinner kidlisp pieces by +// crossing fade palettes with motion expressions. +// +// Usage: +// node kidlisp/tools/gen-spinners.mjs → prints 256 sources, one per line +// node kidlisp/tools/gen-spinners.mjs --out file.txt → writes to file +// node kidlisp/tools/gen-spinners.mjs --json → JSON with palette+motion+source per entry + +import { writeFileSync } from "node:fs"; + +const PALETTES = [ + // Classic rainbow tokens (AC special fade tokens) + { name: "red-rainbow", fade: "red-rainbow" }, + { name: "orange-rainbow", fade: "orange-rainbow" }, + { name: "yellow-rainbow", fade: "yellow-rainbow" }, + { name: "green-rainbow", fade: "green-rainbow" }, + // Single-color sandwiches (ROYGBIV anchor) + { name: "red-band", fade: "black-red-black" }, + { name: "orange-band", fade: "black-orange-black" }, + { name: "yellow-band", fade: "black-yellow-black" }, + { name: "green-band", fade: "black-green-black" }, + { name: "blue-band", fade: "black-blue-black" }, + { name: "indigo-band", fade: "black-indigo-black" }, + { name: "violet-band", fade: "black-violet-black" }, + // Themed multi-stop gradients + { name: "sunset", fade: "black-orange-red-purple-black" }, + { name: "ocean", fade: "navy-blue-cyan-teal-navy" }, + { name: "fire", fade: "black-red-yellow-red-black" }, + { name: "ice", fade: "white-cyan-blue-indigo-black" }, + { name: "vapor", fade: "pink-cyan-purple-cyan-pink" }, + { name: "midnight", fade: "black-purple-navy-purple-black" }, + { name: "lava", fade: "red-orange-black-orange-red" }, + { name: "aurora", fade: "green-cyan-purple-cyan-green" }, + { name: "sakura", fade: "white-pink-magenta-pink-white" }, + { name: "candy", fade: "pink-magenta-yellow-cyan-white" }, + { name: "jungle", fade: "darkgreen-green-yellow-green-darkgreen" }, + { name: "autumn", fade: "brown-orange-red-orange-brown" }, + { name: "cosmos", fade: "black-purple-pink-orange-yellow" }, + // Original mint reference + { name: "purple-red", fade: "black-purple-red-purple-black" }, + // Full ROYGBIV + { name: "roygbiv", fade: "red-orange-yellow-green-blue-indigo-violet" }, + { name: "roygbiv-reverse", fade: "violet-indigo-blue-green-yellow-orange-red" }, +]; + +const MOTIONS = [ + { name: "spin", expr: "frame" }, + { name: "fast-spin", expr: "(* frame 5)" }, + { name: "slow-spin", expr: "(/ frame 3)" }, + { name: "swing", expr: "(+ frame (* (sin (* frame 0.05)) 60))" }, + { name: "wobble", expr: "(+ frame (* (sin frame) 50))" }, + { name: "accelerate", expr: "(* frame frame 0.001)" }, + { name: "decelerate", expr: "(* (sqrt frame) 30)" }, + { name: "bounce", expr: "(abs (* (sin (* frame 0.03)) 360))" }, + { name: "pendulum", expr: "(* (sin (* frame 0.02)) 180)" }, + { name: "pulse", expr: "(+ frame (* (sin (* frame 0.1)) 30))" }, + { name: "jitter", expr: "(+ frame (* (sin (* frame 2)) 20))" }, + { name: "reverse", expr: "(- 0 frame)" }, + { name: "chaos", expr: "(+ frame (* (sin (* frame 0.07)) (cos (* frame 0.13)) 200))" }, + { name: "meditative", expr: "(* (sin (* frame 0.01)) 90)" }, + { name: "exp-sin", expr: "(* frame (sin (* frame 0.05)))" }, + { name: "double", expr: "(+ frame (* (sin (* frame 0.1)) (sin (* frame 0.03)) 100))" }, +]; + +const args = process.argv.slice(2); +const opts = {}; +for (let i = 0; i < args.length; i++) { + if (args[i] === "--out") opts.out = args[++i]; + else if (args[i] === "--json") opts.json = true; + else if (args[i] === "--catalog") opts.catalog = true; +} + +const entries = []; +let n = 0; +for (const p of PALETTES) { + for (const m of MOTIONS) { + entries.push({ + index: n++, + palette: p.name, + motion: m.name, + source: `(wipe fade:${p.fade}:${m.expr})`, + }); + if (n >= 256) break; + } + if (n >= 256) break; +} + +let output; +if (opts.json) { + output = JSON.stringify(entries, null, 2); +} else if (opts.catalog) { + output = entries + .map((e) => `${String(e.index).padStart(3, "0")} ${e.palette.padEnd(20)} ${e.motion.padEnd(14)} ${e.source}`) + .join("\n"); +} else { + output = entries.map((e) => e.source).join("\n"); +} + +if (opts.out) { + writeFileSync(opts.out, output); + console.error(`wrote ${entries.length} entries to ${opts.out}`); +} else { + console.log(output); +} diff --git a/kidlisp/tools/preview-anim.mjs b/kidlisp/tools/preview-anim.mjs new file mode 100644 index 000000000..d48966c60 --- /dev/null +++ b/kidlisp/tools/preview-anim.mjs @@ -0,0 +1,347 @@ +#!/usr/bin/env node +// preview-anim.mjs — render a KidLisp piece to an animated webp or mp4 +// by capturing a frame sequence from the local dev server via puppeteer. +// Output format is chosen by extension on --out (.webp via img2webp, +// .mp4 via ffmpeg libx264). +// +// Capture mode: +// --realtime (default for mp4): uses Chrome's Page.startScreencast to +// stream frames as the page renders — captures AC's actual ~60fps tick. +// --no-realtime (or default for webp): polls page.screenshot() at the +// requested --fps. Smaller fps yields smaller webp. +// +// Usage: +// node kidlisp/tools/preview-anim.mjs "(wipe fade:red-rainbow:frame)" \ +// --out ~/Desktop/spin.mp4 +// +// Flags: +// --out PATH output animation (.webp or .mp4) +// (default: ~/Desktop/kidlisp-anim.webp) +// --fps N output frame rate (default: 60 for mp4, 20 for webp) +// --duration MS total animation length in ms (default: 4000) +// --size WxH per-frame size (default: 512x512) +// --base URL dev server base (default: http://localhost:8888) +// --boot MS boot settle time before capture starts (default: 2500) +// --quality 0-100 webp quality (default: 80) / mp4 CRF inverse: +// mp4 CRF = round((100-quality) * 0.4), clamped 14..32 +// --scale N output pixel multiplier (puppeteer deviceScaleFactor). +// AC's VIRTUAL canvas stays at --size; the captured +// frames are size × scale physical pixels. +// (default: 1) +// --density N DEPRECATED alias for --scale (kept for back-compat) +// --realtime force screencast-mode capture +// --no-realtime force poll-mode capture +// --keep keep individual frame PNGs/JPEGs in a sibling dir + +import puppeteer from "puppeteer"; +import { mkdtempSync, rmSync, mkdirSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; + +const args = process.argv.slice(2); +if (!args.length || args[0].startsWith("--")) { + console.error("usage: preview-anim.mjs '' [flags]"); + process.exit(1); +} + +const source = args.shift(); +const opts = parseFlags(args); +const out = expandHome(opts.out || "~/Desktop/kidlisp-anim.webp"); +const fmt = out.toLowerCase().endsWith(".mp4") ? "mp4" : "webp"; +const fps = Number(opts.fps || (fmt === "mp4" ? 60 : 20)); +const duration = Number(opts.duration || 4000); +const [w, h] = (opts.size || "320x240").split("x").map(Number); +const base = opts.base || "http://localhost:8888"; +const bootMs = Number(opts.boot || 4000); +const quality = Number(opts.quality || (fmt === "mp4" ? 95 : 85)); +const scale = Number(opts.scale || opts.density || 1); +const realtime = opts["no-realtime"] ? false : opts.realtime || fmt === "mp4"; + +const totalFrames = Math.round((duration / 1000) * fps); +const frameInterval = 1000 / fps; + +// AC renders at native virtual resolution (--size, density=1) for sharpness +// and 60fps, then ffmpeg nearest-neighbor upscales to --size × --scale on +// encode. Each AC virtual pixel becomes a crisp scale×scale block. +const outW = w * scale; +const outH = h * scale; +const encoded = encodeKidlispForUrl(source); +// `tv=true` = AC's headless-capture mode. `nogap=true` removes chrome border. +// `nolabel=true` removes the top source-code label band (default: keep label). +const labelOff = opts["no-label"] || opts.nolabel ? "&nolabel=true" : ""; +const url = `${base}/${encoded}?nogap=true&tv=true&density=1${labelOff}`; + +const work = mkdtempSync(join(tmpdir(), "kidlisp-anim-")); + +console.log("source:", source); +console.log("url:", url); +console.log( + `capturing ${realtime ? "via screencast" : `${totalFrames} polled frames`} @ ${fps}fps (${duration}ms total)`, +); +console.log("out:", out); + +const CHROME_FALLBACK = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const launchOpts = { + headless: "new", + args: ["--no-sandbox", "--disable-setuid-sandbox", "--mute-audio"], +}; +if (existsSync(CHROME_FALLBACK)) launchOpts.executablePath = CHROME_FALLBACK; +const browser = await puppeteer.launch(launchOpts); + +let captureRate = fps; // frames captured per second of real-time +let frameExt = realtime ? "jpg" : "png"; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: w, height: h, deviceScaleFactor: 1 }); + + // AC logs "[QR] ✅ Cached QR with MatrixChunky8 glyphs" the first frame + // the QR overlay successfully draws with its label. We watch for this + // line: it's the definitive signal that font_1 + MatrixChunky8 glyphs + // are loaded AND the QR cache entry includes the rendered label text. + let qrCached = false; + page.on("console", (msg) => { + const txt = msg.text(); + if (txt.includes("[QR] ✅ Cached QR")) qrCached = true; + }); + + // Pass 1: warm the glyph cache by letting AC render until the QR overlay + // caches its label. The 2-pass approach is still useful because pass 1 + // writes the BDF glyph atlases to IndexedDB. + console.log("warm pass: populating glyph cache..."); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await waitForReady(page, bootMs); + const qrDeadline = Date.now() + 15000; + while (!qrCached && Date.now() < qrDeadline) { + await new Promise((r) => setTimeout(r, 200)); + } + if (qrCached) console.log(" QR label confirmed cached"); + else console.warn(" QR cache log not seen — label may be missing"); + + // Pass 2: reload — glyphs now in IndexedDB, atlases populate instantly, + // and `frame` resets to 0 so we start the capture at a clean t=0. + qrCached = false; + console.log("capture pass: reloading with warm cache..."); + await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 }); + await waitForReady(page, bootMs); + // Explicitly prewarm the glyph memory cache from IndexedDB. This makes + // the Proxy at Typeface.glyphs[char] resolve SYNCHRONOUSLY for any char + // that was loaded in pass 1, so the QR overlay's allGlyphsLoaded check + // passes on its first paint. + const prewarmed = await page.evaluate(async () => { + if (!window.acGlyphCache?.preWarm) return null; + const m = await window.acGlyphCache.preWarm("MatrixChunky8"); + const f = await window.acGlyphCache.preWarm("font_1"); + return { matrixChunky8: m, font_1: f }; + }); + if (prewarmed) { + console.log(` glyph memory cache: MatrixChunky8=${prewarmed.matrixChunky8}, font_1=${prewarmed.font_1}`); + } + const qrDeadline2 = Date.now() + 10000; + while (!qrCached && Date.now() < qrDeadline2) { + await new Promise((r) => setTimeout(r, 200)); + } + if (qrCached) console.log(" QR label confirmed cached (pass 2)"); + else console.warn(" QR label cache still missing — capturing anyway"); + console.log("ready, capturing..."); + + if (realtime) { + captureRate = await captureScreencast(page, work, duration); + console.log(`captured at ~${captureRate.toFixed(1)} fps`); + } else { + const startTime = Date.now(); + let lastLog = 0; + for (let i = 0; i < totalFrames; i++) { + const targetTime = startTime + i * frameInterval; + const now = Date.now(); + if (now < targetTime) await new Promise((r) => setTimeout(r, targetTime - now)); + const framePath = join(work, `f${String(i).padStart(4, "0")}.png`); + await page.screenshot({ path: framePath, type: "png" }); + if (i - lastLog >= 10 || i === totalFrames - 1) { + console.log(` ${i + 1}/${totalFrames}`); + lastLog = i; + } + } + } + await page.close(); +} finally { + await browser.close(); +} + +console.log(`encoding ${fmt}...`); +if (fmt === "mp4") { + // quality 100 → CRF 10, 90 → 12, 80 → 14, 50 → 20 (lower CRF = sharper) + const crf = Math.max(8, Math.min(32, Math.round(30 - quality * 0.2))); + await encodeMp4(work, out, captureRate, crf, frameExt, outW, outH); +} else { + // poll-mode webp uses requested fps; screencast-mode webp falls back to captureRate + const outMs = realtime ? Math.round(1000 / captureRate) : Math.round(frameInterval); + await encodeWebp(work, out, outMs, quality, frameExt); +} +const sizeKb = Math.round(statSize(out) / 1024); +console.log(`wrote ${out} (${sizeKb} KB)`); + +if (opts.keep) { + const keepDir = out.replace(/\.(webp|mp4)$/, "-frames"); + mkdirSync(keepDir, { recursive: true }); + for (const f of readdirSync(work)) copyFileSync(join(work, f), join(keepDir, f)); + console.log(`kept individual frames in ${keepDir}`); +} + +rmSync(work, { recursive: true, force: true }); + +// ──────────────────────────────────────────────────────────────────── + +// Block until AC has finished its boot chain and the async font glyph +// loaders have had time to populate. The signals: +// 1. window.preloaded === true — disk-loaded-and-booted fired +// 2. document.fonts.ready — browser font set is settled +// 3. fixed post-settle for the on-demand BDF glyph Proxies that font_1 +// and MatrixChunky8 trigger lazily as text rendering hits each char. +// Plus an active "render probe": we sample two screenshots `probeMs` apart +// and re-settle if their pixel content is still changing in ways that +// suggest glyph atlases are still resolving in. +async function waitForReady(page, bootMs) { + console.log("waiting for AC preload signal..."); + const t0 = Date.now(); + try { + await page.waitForFunction(() => window.preloaded === true, { + timeout: bootMs * 3, + }); + } catch { + console.warn("preload signal timed out; continuing anyway"); + } + try { + await page.evaluate(() => document.fonts && document.fonts.ready); + } catch {} + const waited = Date.now() - t0; + // Generous fixed settle so MatrixChunky8 + on-demand BDF glyph loads + // (which fire after disk-loaded-and-booted) finish populating. + const settle = Math.max(3000, bootMs - waited); + console.log(` preload after ${waited}ms, settling ${settle}ms more`); + await new Promise((r) => setTimeout(r, settle)); +} + +async function captureScreencast(page, dir, durationMs) { + const client = await page.target().createCDPSession(); + let frameIdx = 0; + let firstTs = null; + let lastTs = null; + const writes = []; + + const onFrame = ({ data, sessionId, metadata }) => { + if (firstTs === null) firstTs = metadata.timestamp; + lastTs = metadata.timestamp; + const buf = Buffer.from(data, "base64"); + const path = join(dir, `f${String(frameIdx++).padStart(5, "0")}.jpg`); + writes.push(writeFile(path, buf)); + // ack so Chrome will send the next frame + client.send("Page.screencastFrameAck", { sessionId }).catch(() => {}); + }; + client.on("Page.screencastFrame", onFrame); + + await client.send("Page.startScreencast", { + format: "jpeg", + quality: 100, + maxWidth: w, + maxHeight: h, + everyNthFrame: 1, + }); + await new Promise((r) => setTimeout(r, durationMs)); + await client.send("Page.stopScreencast"); + await Promise.all(writes); + await client.detach(); + + if (frameIdx === 0) return 0; + const elapsed = (lastTs - firstTs) || durationMs / 1000; + return frameIdx / elapsed; +} + +function encodeWebp(dir, outPath, frameMs, q, ext = "png") { + return new Promise((resolve, reject) => { + const frames = readdirSync(dir) + .filter((f) => f.endsWith("." + ext)) + .sort() + .map((f) => join(dir, f)); + const args = [ + "-loop", "0", + "-d", String(Math.round(frameMs)), + "-q", String(q), + "-m", "4", + ...frames, + "-o", outPath, + ]; + const proc = spawn("img2webp", args, { stdio: ["ignore", "pipe", "inherit"] }); + proc.stdout.on("data", () => {}); // drain + proc.on("error", reject); + proc.on("exit", (c) => (c === 0 ? resolve() : reject(new Error(`img2webp exit ${c}`)))); + }); +} + +function encodeMp4(dir, outPath, fps, crf, ext = "jpg", outW, outH) { + return new Promise((resolve, reject) => { + const pattern = ext === "jpg" ? "f%05d.jpg" : "f%04d.png"; + // If outW/outH given and different from source, nearest-neighbor upscale + // for crisp chunky pixels. Otherwise just round to even for yuv420p. + const vf = outW && outH + ? `scale=${outW}:${outH}:flags=neighbor` + : "scale=trunc(iw/2)*2:trunc(ih/2)*2"; + const args = [ + "-y", + "-framerate", String(fps), + "-i", join(dir, pattern), + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-preset", "veryslow", + "-crf", String(crf), + "-tune", "animation", + "-g", String(Math.max(1, Math.round(fps))), + "-movflags", "+faststart", + "-vf", vf, + outPath, + ]; + const proc = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "ignore"] }); + proc.on("error", reject); + proc.on("exit", (c) => (c === 0 ? resolve() : reject(new Error(`ffmpeg exit ${c}`)))); + }); +} + +function statSize(p) { + try { + return statSync(p).size; + } catch { + return 0; + } +} + +function parseFlags(arr) { + const o = {}; + for (let i = 0; i < arr.length; i++) { + const a = arr[i]; + if (!a.startsWith("--")) continue; + const k = a.slice(2); + const next = arr[i + 1]; + if (next === undefined || next.startsWith("--")) { + o[k] = true; + } else { + o[k] = next; + i++; + } + } + return o; +} + +function expandHome(p) { + return p.startsWith("~/") ? join(process.env.HOME, p.slice(2)) : p; +} + +function encodeKidlispForUrl(src) { + return src + .replace(/ /g, "_") + .replace(/\n/g, "§") + .replace(/%/g, "¤") + .replace(/;/g, "¨") + .replace(/#/g, "%23"); +} diff --git a/kidlisp/tools/preview-frames.mjs b/kidlisp/tools/preview-frames.mjs new file mode 100644 index 000000000..0ed4dda7f --- /dev/null +++ b/kidlisp/tools/preview-frames.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +// preview-frames.mjs — render a KidLisp piece at multiple real-time +// elapsed offsets via the local dev server + puppeteer, then stitch +// into a contact sheet. +// +// Usage: +// node kidlisp/tools/preview-frames.mjs "(wipe fade:black-purple-red-purple-black:(frame))" \ +// --out ~/Desktop/spin-test.png +// +// Flags: +// --out PATH output contact-sheet PNG (default: ~/Desktop/kidlisp-preview.png) +// --delays CSV per-frame real-time elapsed ms after boot +// (default: 500,1000,1500,2000,3000,5000) +// --cols N contact-sheet columns (default: 3) +// --size WxH per-frame size (default: 512x512) +// --base URL dev server base (default: http://localhost:8888) +// --boot MS boot settle time before counting frames (default: 2500) +// --label STR title strip across top of sheet +// --keep keep individual frame PNGs in a sibling dir + +import puppeteer from "puppeteer"; +import { mkdtempSync, rmSync, mkdirSync, copyFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sharp from "sharp"; + +const args = process.argv.slice(2); +if (!args.length || args[0].startsWith("--")) { + console.error("usage: preview-frames.mjs '' [flags]"); + process.exit(1); +} + +const source = args.shift(); +const opts = parseFlags(args); +const out = expandHome(opts.out || "~/Desktop/kidlisp-preview.png"); +const delays = (opts.delays || "500,1000,1500,2000,3000,5000") + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => n > 0); +const cols = Number(opts.cols || 3); +const [w, h] = (opts.size || "512x512").split("x").map(Number); +const base = opts.base || "http://localhost:8888"; +const bootMs = Number(opts.boot || 2500); +const label = opts.label || source; + +const encoded = encodeKidlispForUrl(source); +const url = `${base}/${encoded}?nogap=true&density=1`; + +const work = mkdtempSync(join(tmpdir(), "kidlisp-preview-")); +const framePaths = []; + +console.log("source:", source); +console.log("url:", url); +console.log("delays:", delays, "ms (after", bootMs, "ms boot)"); + +const CHROME_FALLBACK = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +const launchOpts = { + headless: "new", + args: ["--no-sandbox", "--disable-setuid-sandbox", "--mute-audio"], +}; +if (existsSync(CHROME_FALLBACK)) launchOpts.executablePath = CHROME_FALLBACK; +const browser = await puppeteer.launch(launchOpts); + +try { + for (let i = 0; i < delays.length; i++) { + const delay = delays[i]; + const outPath = join(work, `frame-${String(i).padStart(2, "0")}-d${delay}.png`); + console.log(` [${i + 1}/${delays.length}] elapsed=${delay}ms`); + + const page = await browser.newPage(); + await page.setViewport({ width: w, height: h, deviceScaleFactor: 1 }); + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + // Wait for AC to boot + render `delay` ms of frames + await new Promise((r) => setTimeout(r, bootMs + delay)); + await page.screenshot({ path: outPath, type: "png" }); + await page.close(); + framePaths.push({ path: outPath, delay }); + } +} finally { + await browser.close(); +} + +await buildContactSheet(framePaths, out, { cols, w, h, label }); +console.log(`\nwrote ${out}`); + +if (opts.keep) { + const keepDir = out.replace(/\.png$/, "-frames"); + mkdirSync(keepDir, { recursive: true }); + for (const f of framePaths) copyFileSync(f.path, join(keepDir, `d${f.delay}.png`)); + console.log(`kept individual frames in ${keepDir}`); +} + +rmSync(work, { recursive: true, force: true }); + +// ──────────────────────────────────────────────────────────────────── + +function parseFlags(arr) { + const o = {}; + for (let i = 0; i < arr.length; i++) { + const a = arr[i]; + if (!a.startsWith("--")) continue; + const k = a.slice(2); + const next = arr[i + 1]; + if (next === undefined || next.startsWith("--")) { + o[k] = true; + } else { + o[k] = next; + i++; + } + } + return o; +} + +function expandHome(p) { + return p.startsWith("~/") ? join(process.env.HOME, p.slice(2)) : p; +} + +function encodeKidlispForUrl(src) { + return src + .replace(/ /g, "_") + .replace(/\n/g, "§") + .replace(/%/g, "¤") + .replace(/;/g, "¨") + .replace(/#/g, "%23"); +} + +async function buildContactSheet(frames, outPath, { cols, w, h, label }) { + const rows = Math.ceil(frames.length / cols); + const pad = 8; + const labelH = 36; + const captionH = 22; + const cellW = w + pad * 2; + const cellH = h + pad * 2 + captionH; + const totalW = cellW * cols; + const totalH = labelH + cellH * rows; + + const bg = { r: 16, g: 16, b: 20, alpha: 1 }; + + const composites = []; + for (let i = 0; i < frames.length; i++) { + const r = Math.floor(i / cols); + const c = i % cols; + const x = c * cellW + pad; + const y = labelH + r * cellH + pad; + composites.push({ input: frames[i].path, left: x, top: y }); + + const captionSvg = Buffer.from( + `` + + `` + + `elapsed ${frames[i].delay}ms` + + ``, + ); + composites.push({ input: captionSvg, left: x, top: y + h + 2 }); + } + + const titleSvg = Buffer.from( + `` + + `` + + `${escapeXml(label)}` + + ``, + ); + composites.unshift({ input: titleSvg, left: 0, top: 0 }); + + await sharp({ + create: { width: totalW, height: totalH, channels: 4, background: bg }, + }) + .composite(composites) + .png() + .toFile(outPath); +} + +function escapeXml(s) { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index e11b68f73..faefafa6a 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -15550,7 +15550,7 @@ async function makeFrame({ data: { type, content } }) { // console.log('[MatrixChunky8] Glyph check:', { char, charCode: char.charCodeAt(0), glyph: glyph ? 'exists' : 'missing', glyphType: typeof glyph }); // A real glyph has properties like dwidth, advance, or resolution // The Proxy returns null for missing glyphs - const isRealGlyph = glyph && glyph !== null && + const isRealGlyph = glyph && glyph !== null && !glyph.isPlaceholder && (glyph.dwidth || glyph.advance !== undefined || glyph.resolution); if (!isRealGlyph) { @@ -15951,7 +15951,7 @@ async function makeFrame({ data: { type, content } }) { useMatrixFont = true; for (const char of codeToRender) { const glyph = matrixFont.glyphs[char]; - const isLoaded = glyph && (glyph.pixels || glyph.commands || glyph.resolution); + const isLoaded = glyph && !glyph.isPlaceholder && (glyph.pixels || glyph.commands || glyph.resolution); if (!isLoaded) { useMatrixFont = false; break; diff --git a/system/public/aesthetic.computer/lib/kidlisp.mjs b/system/public/aesthetic.computer/lib/kidlisp.mjs index 7ead34749..c665854ea 100644 --- a/system/public/aesthetic.computer/lib/kidlisp.mjs +++ b/system/public/aesthetic.computer/lib/kidlisp.mjs @@ -9891,6 +9891,54 @@ class KidLisp { // The first element indicates the function to call let [head, ...args] = item; + // 🔗 Re-glue fade strings whose angle param tokenizes as a separate + // sub-expression. The tokenizer breaks (wipe fade:colors:(frame)) into + // ["fade:colors:", ["frame"]] because parens always end a token. + // Detect "fade:" string ending in ":" followed by an s-expr / atom, + // evaluate the expression, and append its value as the angle. + if (args.length >= 2) { + const reglued = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + const next = args[i + 1]; + if ( + typeof a === "string" && + a.startsWith("fade:") && + a.endsWith(":") && + next !== undefined + ) { + let angleValue; + try { + if (Array.isArray(next)) { + angleValue = this.fastEval(next, api, env); + } else if (typeof next === "number") { + angleValue = next; + } else if (typeof next === "string") { + const num = parseFloat(next); + if (!isNaN(num) && /^-?\d+(?:\.\d+)?$/.test(next)) { + angleValue = num; + } else { + const expanded = this.expandFastMathMacros(next); + angleValue = Array.isArray(expanded) + ? this.fastEval(expanded, api, env) + : this.evaluate(next, api, env); + } + } else { + angleValue = next; + } + } catch (e) { + console.warn("Failed to evaluate fade angle expression:", next, e); + angleValue = 0; + } + reglued.push(a + String(angleValue)); + i++; // consume next + continue; + } + reglued.push(a); + } + args = reglued; + } + // Preprocess arguments to evaluate any fade strings args = args.map(arg => { if (typeof arg === "string" && arg.startsWith("fade:")) {