@litfamily/litopencode 1.0.36 → 1.0.37

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.
@@ -5,6 +5,7 @@
5
5
  import { createHash } from "node:crypto";
6
6
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
7
7
  import path from "node:path";
8
+ import { decodePng } from "../png.mjs";
8
9
 
9
10
  export const STAGE_CONTRACT = 17;
10
11
  export const STAGE_NETWORK = 19;
@@ -32,6 +33,11 @@ export function readPlate(stageDir, sub = "plate") {
32
33
  }
33
34
 
34
35
  export const rasterLimits = Object.freeze({ files: 24, bytes: 8 * 1024 * 1024, sameSizeFlipbook: 10 });
36
+ // Keyed overlay clips: frame folders that stage/clips/clips.json declares are held to these caps instead of
37
+ // rasterLimits. A clip is a sticker on the film, never the film: 8-bit RGBA PNG frames with real
38
+ // transparency, at most `side` px on the long side, at most `fps` frames a second.
39
+ export const clipLimits = Object.freeze({ clips: 12, framesPerClip: 360, framesTotal: 1500, bytesPerClip: 40 * 1024 * 1024, bytesTotal: 128 * 1024 * 1024, side: 720, fps: 30 });
40
+ export const clipsDir = "clips";
35
41
  // XML namespace identifiers are names, not requests.
36
42
  const namespaceUris = /https?:\/\/www\.w3\.org\/(?:2000\/svg|1999\/xlink|1999\/xhtml|XML\/1998\/namespace|2000\/xmlns\/?|1998\/Math\/MathML)/gu;
37
43
 
@@ -76,7 +82,7 @@ export function rasterInfo(bytes, ext) {
76
82
  if (type === "IDAT" || type === "IEND") break;
77
83
  offset += 12 + length;
78
84
  }
79
- return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), animated };
85
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), animated, bitDepth: bytes[24], colorType: bytes[25] };
80
86
  }
81
87
  if (ext === ".gif") {
82
88
  const width = bytes.readUInt16LE(6), height = bytes.readUInt16LE(8);
@@ -123,10 +129,108 @@ export function rasterInfo(bytes, ext) {
123
129
  return null;
124
130
  }
125
131
 
126
- // Throws a StageError for the first violation; returns the raster list for pre-decoding.
127
- export function scanStage(stageDir) {
132
+ // ---- declared clips: stage/clips/clips.json and the frame folders it names ----
133
+ const clipFrame = (n) => `frame_${String(n).padStart(4, "0")}.png`;
134
+ const clipId = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/u;
135
+ const clipFolder = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u;
136
+
137
+ // The clips stage/clips/clips.json declares ([] without the file), checked for shape only. Throws exit 17.
138
+ export function declaredClips(stageDir, limits = clipLimits) {
139
+ const file = path.join(stageDir, clipsDir, "clips.json");
140
+ if (!existsSync(file)) return [];
141
+ let doc;
142
+ try { doc = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw contract(`${clipsDir}/clips.json is not valid JSON (${error.message})`); }
143
+ if (!doc || !Array.isArray(doc.clips)) throw contract(`${clipsDir}/clips.json needs { "clips": [ { "id", "dir", "count", "fps" } ] }`);
144
+ if (doc.clips.length > limits.clips) throw contract(`${doc.clips.length} clips declared (at most ${limits.clips} clips)`);
145
+ const ids = new Set();
146
+ const dirs = new Set();
147
+ return doc.clips.map((entry, n) => {
148
+ const at = `${clipsDir}/clips.json clip #${n + 1}`;
149
+ if (!entry || typeof entry !== "object") throw contract(`${at} is not an object`);
150
+ if (typeof entry.id !== "string" || !clipId.test(entry.id)) throw contract(`${at}: id must be a plain token of letters, digits, - and _ (got ${JSON.stringify(entry.id)})`);
151
+ if (ids.has(entry.id)) throw contract(`${at}: duplicate id ${entry.id}`);
152
+ ids.add(entry.id);
153
+ if (typeof entry.dir !== "string" || !clipFolder.test(entry.dir) || entry.dir.includes("..")) throw contract(`clip "${entry.id}": dir must be a folder name directly under ${clipsDir}/ (got ${JSON.stringify(entry.dir)})`);
154
+ if (dirs.has(entry.dir)) throw contract(`clip "${entry.id}": dir ${entry.dir} is already declared by another clip`);
155
+ dirs.add(entry.dir);
156
+ if (!Number.isInteger(entry.count) || entry.count < 1) throw contract(`clip "${entry.id}": count must be a whole number of frames (got ${JSON.stringify(entry.count)})`);
157
+ if (!Number.isInteger(entry.fps) || entry.fps < 1 || entry.fps > limits.fps) throw contract(`clip "${entry.id}": fps must be a whole number from 1 to ${limits.fps} (got ${JSON.stringify(entry.fps)})`);
158
+ return { id: entry.id, dir: entry.dir, count: entry.count, fps: entry.fps };
159
+ });
160
+ }
161
+
162
+ // Checks every declared clip against its bytes: exactly frame_0001.png..frame_<count>.png in clips/<dir>/,
163
+ // each an 8-bit still RGBA PNG of one size within the overlay cap, real transparency in the first, middle
164
+ // and last frame, and the per-clip and total caps. Returns [{ id, dir, count, fps, width, height, bytes,
165
+ // frames }], `frames` being stage-relative paths.
166
+ export function scanClips(stageDir, { limits = clipLimits, files = listStage(stageDir) } = {}) {
167
+ let totalFrames = 0;
168
+ let totalBytes = 0;
169
+ const clips = declaredClips(stageDir, limits).map((declared) => {
170
+ const prefix = `${clipsDir}/${declared.dir}/`;
171
+ if (declared.count > limits.framesPerClip) throw contract(`clip "${declared.id}": ${declared.count} frames (at most ${limits.framesPerClip} per clip)`);
172
+ const have = new Set(files.filter((rel) => rel.startsWith(prefix) && rasterExtensions.has(path.extname(rel).toLowerCase())).map((rel) => rel.slice(prefix.length)));
173
+ const frames = [];
174
+ for (let n = 1; n <= declared.count; n++) {
175
+ if (!have.has(clipFrame(n))) throw contract(`clip "${declared.id}": ${prefix}${clipFrame(n)} is missing (declared ${declared.count} frames, named frame_0001.png onward)`);
176
+ frames.push(`${prefix}${clipFrame(n)}`);
177
+ }
178
+ if (have.size !== declared.count) {
179
+ const stray = [...have].find((name) => !frames.includes(`${prefix}${name}`));
180
+ throw contract(`clip "${declared.id}": ${prefix}${stray} is not one of the ${declared.count} declared frames; raise count or remove it`);
181
+ }
182
+ let size = null;
183
+ let bytes = 0;
184
+ for (const rel of frames) {
185
+ const data = readFileSync(path.join(stageDir, rel));
186
+ bytes += data.length;
187
+ const info = path.extname(rel).toLowerCase() === ".png" && data.length > 33 ? rasterInfo(data, ".png") : null;
188
+ if (!info) throw contract(`clip "${declared.id}": ${rel} is not a readable PNG`);
189
+ if (info.animated) throw contract(`clip "${declared.id}": ${rel} is an animated image; a clip is a folder of still frames`);
190
+ if (info.colorType !== 6 || info.bitDepth !== 8) throw contract(`clip "${declared.id}": ${rel} must be an 8-bit RGBA PNG with an alpha channel (a keyed frame), not an opaque image`);
191
+ if (Math.max(info.width, info.height) > limits.side) throw contract(`clip "${declared.id}": ${rel} is ${info.width}x${info.height}; a clip is an overlay of at most ${limits.side} px on the long side`);
192
+ if (size && (size.width !== info.width || size.height !== info.height)) throw contract(`clip "${declared.id}": ${rel} is ${info.width}x${info.height} but frame_0001.png is ${size.width}x${size.height}; one size per clip`);
193
+ size = { width: info.width, height: info.height };
194
+ }
195
+ if (bytes > limits.bytesPerClip) throw contract(`clip "${declared.id}": ${(bytes / 1048576).toFixed(1)} MB (at most ${(limits.bytesPerClip / 1048576).toFixed(0)} MB per clip)`);
196
+ for (const rel of [frames[0], frames[Math.floor((frames.length - 1) / 2)], frames.at(-1)]) {
197
+ let pixels;
198
+ try { ({ pixels } = decodePng(readFileSync(path.join(stageDir, rel)))); } catch (error) { throw contract(`clip "${declared.id}": ${rel} cannot be decoded (${error.message}); use a non-interlaced 8-bit RGBA PNG`); }
199
+ let transparent = false;
200
+ for (let i = 3; i < pixels.length && !transparent; i += 4) transparent = pixels[i] < 255;
201
+ if (!transparent) throw contract(`clip "${declared.id}": ${rel} has no transparent pixel; a clip is keyed, not a full-frame video`);
202
+ }
203
+ totalFrames += declared.count;
204
+ totalBytes += bytes;
205
+ return { ...declared, ...size, bytes, frames };
206
+ });
207
+ if (totalFrames > limits.framesTotal) throw contract(`${totalFrames} clip frames in all (at most ${limits.framesTotal})`);
208
+ if (totalBytes > limits.bytesTotal) throw contract(`${(totalBytes / 1048576).toFixed(1)} MB of clip frames in all (at most ${(limits.bytesTotal / 1048576).toFixed(0)} MB)`);
209
+ return clips;
210
+ }
211
+
212
+ // window.LitClips for LitStage.clip: id -> { src, count, fps, width, height }, written from clips.json and
213
+ // the first frame's header. null when the stage declares no clips (or one the scan would refuse).
214
+ export function clipsScript(stageDir) {
215
+ let table;
216
+ try {
217
+ table = Object.fromEntries(declaredClips(stageDir).map((clip) => {
218
+ const first = path.join(stageDir, clipsDir, clip.dir, clipFrame(1));
219
+ const info = rasterInfo(readFileSync(first), ".png");
220
+ return [clip.id, { src: `${clipsDir}/${clip.dir}`, count: clip.count, fps: clip.fps, width: info.width, height: info.height }];
221
+ }));
222
+ } catch { return null; }
223
+ return Object.keys(table).length ? `window.LitClips = ${JSON.stringify(table)};\n` : null;
224
+ }
225
+
226
+ // Throws a StageError for the first violation; returns the raster list for pre-decoding and the declared
227
+ // clips, whose frames are not page rasters. `clips: false` (a cover page) ignores clips.json, so a clip
228
+ // folder there is held to the raster limits.
229
+ export function scanStage(stageDir, { clips: allowClips = true } = {}) {
128
230
  const files = listStage(stageDir);
129
231
  if (!files.includes("index.html")) throw contract("stage/index.html is missing");
232
+ const clips = allowClips ? scanClips(stageDir, { files }) : [];
233
+ const clipped = new Set(clips.flatMap((clip) => clip.frames));
130
234
  const rasters = [];
131
235
  let rasterBytes = 0;
132
236
  const plates = ["plate", "plate-preview"].map((sub) => readPlate(stageDir, sub)).filter(Boolean);
@@ -134,7 +238,7 @@ export function scanStage(stageDir) {
134
238
  for (const rel of files) {
135
239
  const ext = path.extname(rel).toLowerCase();
136
240
  const full = path.join(stageDir, rel);
137
- if (rel === "scene.py" || plated.has(rel)) continue;
241
+ if (rel === "scene.py" || plated.has(rel) || clipped.has(rel)) continue;
138
242
  if (!Object.hasOwn(contentTypes, ext)) throw contract(`${rel}: ${ext || "extensionless"} files are not served on the stage`);
139
243
  if (rasterExtensions.has(ext)) {
140
244
  const bytes = readFileSync(full);
@@ -170,7 +274,7 @@ export function scanStage(stageDir) {
170
274
  const bySize = new Map();
171
275
  for (const raster of rasters) bySize.set(`${raster.width}x${raster.height}`, [...(bySize.get(`${raster.width}x${raster.height}`) ?? []), raster.rel]);
172
276
  for (const [size, list] of bySize) if (list.length >= rasterLimits.sameSizeFlipbook) throw contract(`${list.length} rasters share the size ${size} (a flipbook); rasters are textures and stills, never a frame sequence`);
173
- return { files, rasters };
277
+ return { files, rasters, clips };
174
278
  }
175
279
 
176
280
  // One hash over every stage file's name and bytes: a render records it, the done check compares it.
@@ -227,7 +227,7 @@
227
227
  }
228
228
 
229
229
  // ---- masks and clips ----
230
- const clip = {
230
+ const clipMasks = {
231
231
  // A circle growing from (cx, cy) in percent of the element's box.
232
232
  circle(el, p, { cx = 50, cy = 50, max = 150 } = {}) { el.style.clipPath = `circle(${clamp(p) * max}% at ${cx}% ${cy}%)`; },
233
233
  // A straight wipe from one side: "left", "right", "top" or "bottom".
@@ -370,6 +370,44 @@
370
370
  };
371
371
  }
372
372
 
373
+ // A declared keyed clip (stage/clips/clips.json, served as window.LitClips by /lit/clips.js): one wrapper
374
+ // at `box` over the film, holding the clip's frame for t. The frame is a pure function of film time,
375
+ // floor((t - start) * fps) + 1 within from..to, so no <video> and no real clock is involved. Before
376
+ // `start` and after `to` the wrapper is hidden (null) unless `hold` keeps the last frame or `loop` wraps
377
+ // by frame index. The <img> inside is replaced on a frame change; the renderer waits for its decode.
378
+ // The same name carries the clip-path helpers (clip.circle, clip.wipe, clip.iris), so both keep working.
379
+ function keyedClip({ parent, id, box } = {}) {
380
+ const all = window.LitClips;
381
+ if (!all) throw new Error("LitStage.clip: load <script src=\"/lit/clips.js\"></script> after the kit; the stage declares no clips (clips/clips.json)");
382
+ const c = all[id];
383
+ if (!c) throw new Error(`LitStage.clip: no clip "${id}" in clips/clips.json (declared: ${Object.keys(all).join(", ")})`);
384
+ if (!parent || !box || !(box.w > 0) || !(box.h > 0)) throw new Error("LitStage.clip: needs { parent, id, box: { x, y, w, h } }");
385
+ const wrap = document.createElement("div");
386
+ wrap.setAttribute("data-lit-clip", id);
387
+ Object.assign(wrap.style, { position: "absolute", left: `${box.x}px`, top: `${box.y}px`, width: `${box.w}px`, height: `${box.h}px`, display: "none", pointerEvents: "none" });
388
+ parent.append(wrap);
389
+ let shown = 0, img = null;
390
+ return (t, { start = 0, from = 1, to = c.count, loop = false, hold = false } = {}) => {
391
+ if (!(from >= 1 && to <= c.count && from <= to)) throw new Error(`LitStage.clip: from..to must lie within 1..${c.count} (got ${from}..${to})`);
392
+ const n = to - from + 1;
393
+ const k = Math.floor((t - start) * c.fps + 1e-6);
394
+ const i = k < 0 ? 0 : k < n ? from + k : loop ? from + (k % n) : hold ? to : 0;
395
+ wrap.style.display = i ? "block" : "none";
396
+ if (!i) return null;
397
+ if (i !== shown) {
398
+ const next = document.createElement("img");
399
+ next.src = `${c.src}/frame_${String(i).padStart(4, "0")}.png`;
400
+ next.alt = "";
401
+ Object.assign(next.style, { display: "block", width: "100%", height: "100%", objectFit: "contain" });
402
+ if (img) img.replaceWith(next); else wrap.append(next);
403
+ img = next;
404
+ shown = i;
405
+ }
406
+ return wrap;
407
+ };
408
+ }
409
+ const clip = Object.assign(keyedClip, clipMasks);
410
+
373
411
  // ---- the stage contract ----
374
412
  let definition = null;
375
413
  function define(spec) {
@@ -33,7 +33,8 @@ mode (the stage and type paths) is unchanged and takes every other request.
33
33
  The renderer serves only that folder, the loop kit at `/lit/loop-kit.js` and the faces at
34
34
  `/lit/fonts.css`. The stage path's static scan applies unchanged: no network, no `<video>`,
35
35
  `<audio>` or `<iframe>`, no workers, no WebSocket, at most 24 rasters and 8 MB, no animated
36
- raster, no flipbook of same-size frames.
36
+ raster, no flipbook of same-size frames; a cover takes no keyed clips either, so a folder of
37
+ frames there is a flipbook.
37
38
  - `<out>/assets.json`: every raster the page can load (below).
38
39
 
39
40
  Start from a recipe: copy `recipes/<name>/index.html` from the installed skill into `cover/`, then
@@ -39,7 +39,7 @@ warning, and the pre-warm state naming what is missing and the command that fixe
39
39
  | 14 | BLOCKED_DEPS_NOT_PREWARMED | the pinned packages are absent (or Tier-3 models are requested) | `litopencode motion-runtime install` outside the session |
40
40
  | 15 | BLOCKED_FONT_FETCH | a required font is missing or its bytes do not match the pin | `litopencode motion-runtime install`; a mismatch is never accepted |
41
41
  | 16 | BLOCKED_TREATMENT_INVALID | `treatment.json` is missing or a field breaks its rule; the message names the field | fix that field (`treatment.md`) |
42
- | 17 | STAGE_CONTRACT_ERROR | the stage page broke its contract: no `LitStage.define`, a wrong size, a forbidden element or API, a flipbook or animated raster, a path outside `stage/`, or a copy line never found on screen | fix the page (`stage.md`) |
42
+ | 17 | STAGE_CONTRACT_ERROR | the stage page broke its contract: no `LitStage.define`, a wrong size, a forbidden element or API, a flipbook or animated raster, a keyed clip that breaks its declaration or limits, a path outside `stage/`, or a copy line never found on screen | fix the page (`stage.md`) |
43
43
  | 18 | STAGE_NONDETERMINISTIC | a sample frame differs between the master and a fresh replay from 0; the message names the frame and the first region | remove the clock or random source the renderer does not drive |
44
44
  | 19 | STAGE_NETWORK_REQUEST | the page requested something outside the stage origin, or a stage file names an external URL | make the asset local |
45
45
  | 20 | SOUND_INVALID | the planned track is missing from the film, too long or short, too loud, or a generated bed starts silent; a score that cannot be built | fix the track or its plan |
@@ -16,7 +16,7 @@ loop time the renderer seeks in any order, with the loop kit instead of this one
16
16
  into the stage directory.
17
17
  - Rasters are textures and stills, never a frame sequence: at most 24 raster files and 8 MB in total.
18
18
  Ten or more rasters of one size (a flipbook), or any animated GIF, APNG or WebP, stops the render
19
- with exit 17.
19
+ with exit 17. The one frame sequence a page may carry is a declared keyed clip (Clips).
20
20
  - `<out>/stage/assets/`: the real logo, screenshots and README images of a named product, written by
21
21
  `render.mjs assets` (`treatment.md`, Named products). Show them; never redraw them.
22
22
  - A film about a thing with a back and an inside takes the 3D route: the renderer serves a pinned
@@ -76,7 +76,7 @@ unless the film needs another structure.
76
76
 
77
77
  | Stops with | What |
78
78
  | --- | --- |
79
- | 17 `STAGE_CONTRACT_ERROR` | `<video>`, `<audio>`, `<iframe>`, `<object>`, `<embed>`, `<frame>`; `new Audio()`, `AudioContext`, `OfflineAudioContext`; `Worker`, `SharedWorker`, service workers; no `LitStage.define`; a wrong size; a flipbook or animated raster; a copy line never found on screen |
79
+ | 17 `STAGE_CONTRACT_ERROR` | `<video>`, `<audio>`, `<iframe>`, `<object>`, `<embed>`, `<frame>`; `new Audio()`, `AudioContext`, `OfflineAudioContext`; `Worker`, `SharedWorker`, service workers; no `LitStage.define`; a wrong size; a flipbook or animated raster; a clip that breaks its declaration or limits; a copy line never found on screen |
80
80
  | 18 `STAGE_NONDETERMINISTIC` | a frame that differs between two fresh replays: wall-clock reads such as `performance.timeOrigin`, `crypto.getRandomValues`, or state that depends on how fast frames arrive |
81
81
  | 19 `STAGE_NETWORK_REQUEST` | any request outside the stage origin; an absolute `http(s)://` or `//host` URL in a stage file (W3C namespace URIs such as `http://www.w3.org/2000/svg` are fine); `preconnect`, `dns-prefetch`, `prefetch` or `prerender` links; `WebSocket`, `WebTransport`, `RTCPeerConnection` |
82
82
 
@@ -147,6 +147,52 @@ LitStage.text(signEl, { decor: true }); // illustrative text i
147
147
  LitStage.text({ content: "…", x, y, w, h }); // canvas or WebGL text, every frame it is drawn
148
148
  ```
149
149
 
150
+ Keyed footage (a declared clip, see Clips): `LitStage.clip` is a function as well as the mask
151
+ namespace above, so `LitStage.clip.wipe(el, p)` and the keyed call live side by side.
152
+
153
+ ```js
154
+ const boom = LitStage.clip({ parent: stageEl, id: "boom", box: { x: 1200, y: 300, w: 520, h: 511 } });
155
+ boom(t, { start: 2.1 }); // inside render(t); returns the wrapper, or null while hidden
156
+ ```
157
+
158
+ ## Clips
159
+
160
+ Real keyed footage (a reaction clip, a spinning-cat loop shot on a green screen) joins the film as a
161
+ layer inside the page. The video is keyed outside the renderer into one 8-bit RGBA PNG per frame, the
162
+ frames are declared, and the page picks the frame from film time. A declared clip is exempt from the
163
+ flipbook and count rule; every other raster keeps its limits. Nothing is encoded by hand: the frames
164
+ are captured with the page, so the flash audit, the determinism replay and the text checks run on the
165
+ final pixels as for any other film.
166
+
167
+ 1. Copy each clip's frames into `stage/clips/<dir>/frame_0001.png`, `frame_0002.png` and so on, with
168
+ no gaps and no other image in the folder.
169
+ 2. Declare them in `stage/clips/clips.json`:
170
+
171
+ ```json
172
+ { "clips": [ { "id": "boom", "dir": "01-vine-boom", "count": 122, "fps": 30 } ] }
173
+ ```
174
+
175
+ `id` is the name the page uses; `dir` is a folder directly under `clips/`; `count` is the number of
176
+ frames; `fps` is the clip's own rate, 1 to 30. A clip used twice is declared once.
177
+ 3. The page loads `<script src="/lit/clips.js"></script>` after the kit, registers each use once with
178
+ `LitStage.clip({ parent, id, box })` and calls the returned `show(t, opts)` from `render(t)`.
179
+ `parent` is the element the clip sits in; `box` is its place in pixels, the frame fitted inside it.
180
+ `show` returns the wrapper element, or `null` while hidden; scale, fade or move that wrapper from
181
+ `t` like any other layer. The frame is `floor((t - start) * fps) + 1`, counted from `from`, so the
182
+ cue is plain arithmetic: a clip whose own sound starts at frame 85 and must land on a cue at 0.12 s
183
+ has `start = 0.12 - 84 / 30`. Options: `start` (film seconds, default 0), `from` and `to` (the
184
+ frame range, default the whole clip), `loop` (wrap by frame index), `hold` (keep the last frame).
185
+ Before `start` and past `to`, without `loop` or `hold`, the clip is hidden.
186
+
187
+ The scan refuses, with exit 17 and the clip named: more than 12 clips, more than 360 frames in a clip
188
+ or 1500 in all, 40 MB in a clip or 128 MB in all, a frame whose long side is above 720 px (a clip is
189
+ an overlay, not the film), a rate above 30 fps, frames of mixed sizes, a missing or extra frame, a
190
+ frame that is not an 8-bit RGBA PNG or that has no transparent pixel in its first, middle and last
191
+ frame (an opaque video is not keyed), an animated image, and a duplicate `id`. A folder that is not
192
+ declared, or any frame sequence beside a declared clip, is a flipbook as before. A cover takes no
193
+ clips. The text checks read DOM text, so a clip laid over a line is yours to catch on the stills. The
194
+ manifest and the report list each clip.
195
+
150
196
  ## How the QA reads the stage
151
197
 
152
198
  - Every `copy.lines` entry must appear on screen at some beat; a missing line stops the render with
@@ -3389,7 +3389,7 @@
3389
3389
  },
3390
3390
  {
3391
3391
  "path": "engine/cover/render.mjs",
3392
- "sha256": "8b5cafb3830d6723c882af9ed704e68d8b7ba26fd124f9eff7c8ef9185fc3875"
3392
+ "sha256": "8f819692910d35959aa86b61a271ca6fe4c74547d549a1c8d90da07bdffd62cf"
3393
3393
  },
3394
3394
  {
3395
3395
  "path": "engine/cover/sheet.mjs",
@@ -3477,7 +3477,7 @@
3477
3477
  },
3478
3478
  {
3479
3479
  "path": "engine/stage/capture.mjs",
3480
- "sha256": "5174a001ed40dcaf9dcfa692feb443f1fad771c942a8b1bd2df5a6b261e071c6"
3480
+ "sha256": "58698c9ecfd1eb789aabc9f166706492b1f1f00e1fa8d09449ea62f479ccd558"
3481
3481
  },
3482
3482
  {
3483
3483
  "path": "engine/stage/clock.js",
@@ -3493,7 +3493,7 @@
3493
3493
  },
3494
3494
  {
3495
3495
  "path": "engine/stage/gate.mjs",
3496
- "sha256": "f806003a86d8d76c10934f644c8ee596a7b96bfab0ecceed78a71b9017722334"
3496
+ "sha256": "81fa8be4d4ad45fe9b799d5b6765923a0de4fa2507223a9e26faed3420112473"
3497
3497
  },
3498
3498
  {
3499
3499
  "path": "engine/stage/loop-kit.js",
@@ -3509,15 +3509,15 @@
3509
3509
  },
3510
3510
  {
3511
3511
  "path": "engine/stage/render.mjs",
3512
- "sha256": "e01927e704083babb069e5664307cf179329231996c6ce8614eb80062e4ed6d8"
3512
+ "sha256": "3f47ad9ed8e19763242af0b05359ae09031b76899939e170ef61bddbf9e261cf"
3513
3513
  },
3514
3514
  {
3515
3515
  "path": "engine/stage/scan.mjs",
3516
- "sha256": "478cd41f852f71a2d8c6ea6e53abcea95c4ca53a13399e0e8faebc89df87e70b"
3516
+ "sha256": "0caaaeebcce7fd3b4d5c3ac5a1bf0f28d07c5c1f82202637a118941b309d3699"
3517
3517
  },
3518
3518
  {
3519
3519
  "path": "engine/stage/stage-kit.js",
3520
- "sha256": "a38d5c22f2f4148009b0ab89be228833457495565c559d061b36e3d14f694a76"
3520
+ "sha256": "72776c1fd3642c3925da48adbc342234a4e8599e46d49e89962b4d77c7e6a7d2"
3521
3521
  },
3522
3522
  {
3523
3523
  "path": "engine/stills.mjs",
@@ -3741,7 +3741,7 @@
3741
3741
  },
3742
3742
  {
3743
3743
  "path": "references/cover.md",
3744
- "sha256": "ad7e980530282af2a48cd185d1fbbfecbcb1886377ff08eea16d2458b70fa157"
3744
+ "sha256": "93658c3f3a08b7a9c71443df92db418c6839396a389ac480bd7ccf22faf4eb54"
3745
3745
  },
3746
3746
  {
3747
3747
  "path": "references/craft-loop.md",
@@ -3753,7 +3753,7 @@
3753
3753
  },
3754
3754
  {
3755
3755
  "path": "references/runtime.md",
3756
- "sha256": "c0c87c23a2d404df995fc4d5a6a544728709046c65a0bb84dcc3c782b92d6ef7"
3756
+ "sha256": "15dc951960b2ae4b204aa094792d29e55829815894524556ebc00550655b325d"
3757
3757
  },
3758
3758
  {
3759
3759
  "path": "references/sound.md",
@@ -3761,7 +3761,7 @@
3761
3761
  },
3762
3762
  {
3763
3763
  "path": "references/stage.md",
3764
- "sha256": "0ecfb0aff75815a88ff0a0f277ab8aff45ff072be0fc0d57a969803246d78cae"
3764
+ "sha256": "129c1ad6b67b629e8f7e022a705a289e889755f67532b311f19ff15cc7fa8251"
3765
3765
  },
3766
3766
  {
3767
3767
  "path": "references/studio.md",