@glissade/cli 0.11.0-pre.1 → 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.
- package/dist/audioMix.js +16 -1
- package/dist/cacheVerify.js +133 -0
- package/dist/cli.js +227 -2
- package/dist/diff.js +66 -0
- package/dist/fonts.js +122 -0
- package/dist/frameCache.js +299 -0
- package/dist/index.d.ts +506 -8
- package/dist/index.js +9 -3
- package/dist/loudness.js +254 -0
- package/dist/music.js +1 -1
- package/dist/narrationLintCommand.js +314 -0
- package/dist/prepare.js +1 -1
- package/dist/render.js +153 -13
- package/dist/rolldown-runtime.js +1 -0
- package/dist/shards.js +1 -1
- package/dist/verifyDeterminism.js +302 -0
- package/dist/version.js +26 -0
- package/package.json +10 -10
|
@@ -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 };
|