@glissade/cli 0.11.0 → 0.12.0-pre.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { serializeDisplayList } from "@glissade/scene";
5
+ import { createHash } from "node:crypto";
6
+ import { deflateSync, inflateSync } from "node:zlib";
7
+ //#region src/frameCache.ts
8
+ /**
9
+ * gs render persistent raster cache — the on-disk `.gscache` (DESIGN.md §3.5,
10
+ * 0.12). A content-addressed disk cache so a one-line edit doesn't re-rasterize
11
+ * every blur-heavy frame across runs/shards. This LAYERS ON the in-memory §3.5
12
+ * group-bitmap LRU (`scene/src/raster2d.ts`) — it does NOT replace it: the
13
+ * in-memory cache spans groups WITHIN one process; this spans WHOLE FRAMES across
14
+ * processes/runs.
15
+ *
16
+ * LOCKED scope (per the 0.12 card): WHOLE-FRAME granularity only — the key is over
17
+ * the ENTIRE frame's DisplayList, not per-group (per-group tiling is DEFERRED to
18
+ * 0.13). A hit is the "this frame's content is byte-identical to a prior render"
19
+ * case, so byte-safety is by construction.
20
+ *
21
+ * THE failure mode is SILENT corruption: a stale tile served from an INCOMPLETE
22
+ * key. So the key MUST fold EVERY byte-affecting input (§ key completeness):
23
+ * 1. the frame's DisplayList-snapshot bytes (`serializeDisplayList` — the shipped
24
+ * byte-preserving collapse serializer; resources already inlined to content,
25
+ * and the post-evaluate DisplayList already encodes resolved device
26
+ * transforms, so this captures geometry/paint/transform for a whole frame),
27
+ * 2. the glissade VERSION (any Raster2D-composite / Skia-toolchain change
28
+ * invalidates every frame — bump-on-version is the cheapest correct
29
+ * invalidation; text/font tiles are thereby scoped to the pinned toolchain),
30
+ * 3. the BackendCaps id (filters / shaders / maxTextureSize).
31
+ * `version` and `capsId` have no source inside `scene`, so they are INJECTED via a
32
+ * `CacheKeyContext` from the CLI — NEVER read from disk.
33
+ *
34
+ * HIT == MISS: the HIT path reuses the SAME composite/encode routine as a MISS.
35
+ * The miss path renders, reads raw RGBA, stores it; the hit path loads the stored
36
+ * RGBA back into the backend (`putPixels`) and hands it to the IDENTICAL downstream
37
+ * (`encodePng()` / the ffmpeg pipe), so a hit is byte-identical to a cold render.
38
+ * `mode:'off'` skips the cache entirely → the EXACT current equality baseline.
39
+ *
40
+ * Storage: raw-RGBA + zlib (Node `zlib`), one file per frame keyed by the hash. NO
41
+ * ffmpeg / FFV1. Whole-frame RGBA is ~MBs/frame, so a size-capped LRU ships from
42
+ * day one (access-time ordered, default 2 GB) — an uncapped `.gscache` would eat a
43
+ * dev disk on the first full episode.
44
+ *
45
+ * Concurrency: shards SHARE one `.gscache` dir. The cache is content-addressed
46
+ * (filename IS the key), writes are atomic (temp-file + rename), and LRU is derived
47
+ * from filesystem mtime (touched on read). A delete/regenerate race between shards
48
+ * is benign — a missing entry just re-renders.
49
+ */
50
+ var frameCache_exports = /* @__PURE__ */ __exportAll({
51
+ DEFAULT_CACHE_MAX_SIZE: () => DEFAULT_CACHE_MAX_SIZE,
52
+ FrameCache: () => FrameCache,
53
+ FrameCacheError: () => FrameCacheError,
54
+ capsId: () => capsId,
55
+ clearFrameCache: () => clearFrameCache,
56
+ frameCacheKey: () => frameCacheKey,
57
+ parseCacheMaxSize: () => parseCacheMaxSize,
58
+ probeEntryHeader: () => probeEntryHeader
59
+ });
60
+ /** Default LRU cap: 2 GB of compressed entries (the card's default). */
61
+ const DEFAULT_CACHE_MAX_SIZE = 2 * 1024 * 1024 * 1024;
62
+ /** The on-disk file magic + format version — a format change must invalidate every entry. */
63
+ const MAGIC = 1196639025;
64
+ const HEADER_BYTES = 16;
65
+ var FrameCacheError = class extends Error {
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = "FrameCacheError";
69
+ }
70
+ };
71
+ /**
72
+ * Stable, content-addressed key for ONE whole frame. Folds EVERY byte-affecting
73
+ * input: the DisplayList-snapshot bytes (geometry/paint/transform, resources
74
+ * inlined to content), the glissade version, and the backend caps id. An
75
+ * incomplete key here IS the only failure mode (a stale frame served for changed
76
+ * content), so the NEGATIVE gate test asserts that dropping any component makes
77
+ * `gs cache verify` fail.
78
+ */
79
+ function frameCacheKey(dl, ctx) {
80
+ return createHash("sha256").update(serializeDisplayList(dl)).update("\0").update(ctx.version).update("\0").update(ctx.capsId).digest("hex");
81
+ }
82
+ /** A canonical BackendCaps id (filters sorted, shaders, maxTextureSize) — the §3 caps fold. */
83
+ function capsId(caps) {
84
+ return `caps:f=${[...caps.filters].sort().join(",")};s=${caps.shaders ? 1 : 0};tex=${caps.maxTextureSize}`;
85
+ }
86
+ /**
87
+ * One frame entry on disk: a 16-byte header (magic, format version, width,
88
+ * height) followed by zlib-deflated straight RGBA. Width/height let the loader
89
+ * sanity-check the buffer against the live canvas size before blitting (a
90
+ * defensive guard — a mismatched size would otherwise corrupt the frame).
91
+ */
92
+ function encodeEntry(width, height, rgba) {
93
+ const header = Buffer.alloc(HEADER_BYTES);
94
+ header.writeUInt32BE(MAGIC, 0);
95
+ header.writeUInt32BE(1, 4);
96
+ header.writeUInt32BE(width, 8);
97
+ header.writeUInt32BE(height, 12);
98
+ const body = deflateSync(Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength));
99
+ return Buffer.concat([header, body]);
100
+ }
101
+ function decodeEntry(buf) {
102
+ if (buf.length < HEADER_BYTES || buf.readUInt32BE(0) !== MAGIC) throw new FrameCacheError("corrupt .gscache entry (bad magic)");
103
+ const width = buf.readUInt32BE(8);
104
+ const height = buf.readUInt32BE(12);
105
+ const raw = inflateSync(buf.subarray(HEADER_BYTES));
106
+ return {
107
+ width,
108
+ height,
109
+ rgba: new Uint8ClampedArray(raw.buffer, raw.byteOffset, raw.byteLength)
110
+ };
111
+ }
112
+ /**
113
+ * The persistent whole-frame raster cache. One instance per render process; shards
114
+ * each open their own over the SAME dir (content-addressed + atomic writes make
115
+ * that safe). A `mode:'off'` instance is inert — `get` always misses and `put` is a
116
+ * no-op — so the cache-off path is the exact current baseline.
117
+ */
118
+ var FrameCache = class {
119
+ dir;
120
+ mode;
121
+ maxSize;
122
+ stats = {
123
+ hits: 0,
124
+ misses: 0,
125
+ stored: 0,
126
+ evicted: 0
127
+ };
128
+ constructor(opts) {
129
+ this.dir = opts.dir;
130
+ this.mode = opts.mode;
131
+ this.maxSize = opts.maxSize ?? 2147483648;
132
+ if (this.mode !== "off") mkdirSync(this.dir, { recursive: true });
133
+ }
134
+ pathFor(key) {
135
+ return join(this.dir, `${key}.gscf`);
136
+ }
137
+ /**
138
+ * Look up a whole-frame RGBA by key. On a hit, the file's mtime is touched
139
+ * (access-time bump for the LRU) and the decoded RGBA returned; the caller blits
140
+ * it via `backend.putPixels` and runs the IDENTICAL encode. Returns undefined on
141
+ * a miss or in `mode:'off'`. A corrupt/truncated entry is treated as a miss (it
142
+ * is deleted so it re-renders) rather than a hard error — the cache is advisory.
143
+ */
144
+ get(key) {
145
+ if (this.mode === "off") return void 0;
146
+ const file = this.pathFor(key);
147
+ if (!existsSync(file)) {
148
+ this.stats.misses++;
149
+ return;
150
+ }
151
+ try {
152
+ const { rgba } = decodeEntry(readFileSync(file));
153
+ const now = /* @__PURE__ */ new Date();
154
+ try {
155
+ utimesSync(file, now, now);
156
+ } catch {}
157
+ this.stats.hits++;
158
+ return rgba;
159
+ } catch {
160
+ try {
161
+ unlinkSync(file);
162
+ } catch {}
163
+ this.stats.misses++;
164
+ return;
165
+ }
166
+ }
167
+ /**
168
+ * Store a whole-frame RGBA under its key. No-op in `read-only`/`off`. The write
169
+ * is atomic (temp file + rename) so a crash/interrupt never leaves a torn entry
170
+ * a later run could half-read (the single failure mode). After a store the LRU
171
+ * size cap is enforced.
172
+ */
173
+ put(key, width, height, rgba) {
174
+ if (this.mode !== "read-write") return;
175
+ const file = this.pathFor(key);
176
+ if (existsSync(file)) {
177
+ const now = /* @__PURE__ */ new Date();
178
+ try {
179
+ utimesSync(file, now, now);
180
+ } catch {}
181
+ return;
182
+ }
183
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
184
+ writeFileSync(tmp, encodeEntry(width, height, rgba));
185
+ try {
186
+ renameSync(tmp, file);
187
+ } catch {
188
+ try {
189
+ unlinkSync(tmp);
190
+ } catch {}
191
+ return;
192
+ }
193
+ this.stats.stored++;
194
+ this.enforceSizeCap();
195
+ }
196
+ /**
197
+ * Evict oldest (by mtime) entries until the total compressed size is within
198
+ * `maxSize`. Content-addressed + idempotent, so a delete racing a peer's
199
+ * regeneration is benign. Runs after each store; cheap relative to a frame
200
+ * rasterize.
201
+ */
202
+ enforceSizeCap() {
203
+ let entries;
204
+ try {
205
+ entries = readdirSync(this.dir).filter((f) => f.endsWith(".gscf")).map((f) => {
206
+ const p = join(this.dir, f);
207
+ const st = statSync(p);
208
+ return {
209
+ file: p,
210
+ size: st.size,
211
+ mtime: st.mtimeMs
212
+ };
213
+ });
214
+ } catch {
215
+ return;
216
+ }
217
+ let total = entries.reduce((s, e) => s + e.size, 0);
218
+ if (total <= this.maxSize) return;
219
+ entries.sort((a, b) => a.mtime - b.mtime);
220
+ for (const e of entries) {
221
+ if (total <= this.maxSize) break;
222
+ try {
223
+ unlinkSync(e.file);
224
+ total -= e.size;
225
+ this.stats.evicted++;
226
+ } catch {
227
+ total -= e.size;
228
+ }
229
+ }
230
+ }
231
+ /** Snapshot of hit/miss/store/evict counters (for the render summary + tests). */
232
+ getStats() {
233
+ return { ...this.stats };
234
+ }
235
+ /** Total compressed bytes currently on disk (for tests / a `--cache` summary). */
236
+ diskSize() {
237
+ if (this.mode === "off" || !existsSync(this.dir)) return 0;
238
+ let total = 0;
239
+ for (const f of readdirSync(this.dir)) {
240
+ if (!f.endsWith(".gscf")) continue;
241
+ try {
242
+ total += statSync(join(this.dir, f)).size;
243
+ } catch {}
244
+ }
245
+ return total;
246
+ }
247
+ /** Number of entries currently on disk (tests). */
248
+ entryCount() {
249
+ if (this.mode === "off" || !existsSync(this.dir)) return 0;
250
+ return readdirSync(this.dir).filter((f) => f.endsWith(".gscf")).length;
251
+ }
252
+ };
253
+ /**
254
+ * Parse a `--cache-max-size` flag: a raw byte count or a human size (`2GB`,
255
+ * `512MB`, `1.5g`). Rejects garbage so a typo doesn't silently default. Case- and
256
+ * suffix-insensitive (b/k/m/g, optionally with a trailing `b`).
257
+ */
258
+ function parseCacheMaxSize(flag) {
259
+ const m = /^(\d+(?:\.\d+)?)\s*([kmgt]?)b?$/i.exec(flag.trim());
260
+ if (!m) throw new FrameCacheError(`--cache-max-size must be a byte count or size like '2GB'/'512MB', got '${flag}'`);
261
+ const n = Number(m[1]);
262
+ const unit = (m[2] ?? "").toLowerCase();
263
+ return Math.floor(n * ({
264
+ "": 1,
265
+ k: 1024,
266
+ m: 1024 ** 2,
267
+ g: 1024 ** 3,
268
+ t: 1024 ** 4
269
+ }[unit] ?? 1));
270
+ }
271
+ /**
272
+ * Read the magic-validated header of an entry WITHOUT inflating its body — a cheap
273
+ * existence + size probe used by `gs cache verify` to report what it sampled.
274
+ */
275
+ function probeEntryHeader(file) {
276
+ let fd;
277
+ try {
278
+ fd = openSync(file, "r");
279
+ const header = Buffer.alloc(HEADER_BYTES);
280
+ if (readSync(fd, header, 0, HEADER_BYTES, 0) < HEADER_BYTES || header.readUInt32BE(0) !== MAGIC) return void 0;
281
+ return {
282
+ width: header.readUInt32BE(8),
283
+ height: header.readUInt32BE(12)
284
+ };
285
+ } catch {
286
+ return;
287
+ } finally {
288
+ if (fd !== void 0) closeSync(fd);
289
+ }
290
+ }
291
+ /** Remove the whole `.gscache` dir (a `gs cache clear` convenience / test cleanup). */
292
+ function clearFrameCache(dir) {
293
+ rmSync(dir, {
294
+ recursive: true,
295
+ force: true
296
+ });
297
+ }
298
+ //#endregion
299
+ export { clearFrameCache as a, parseCacheMaxSize as c, capsId as i, probeEntryHeader as l, FrameCache as n, frameCacheKey as o, FrameCacheError as r, frameCache_exports as s, DEFAULT_CACHE_MAX_SIZE as t };