@litfamily/litmuse 0.1.5 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6 — 2026-11-01
4
+
5
+ - `lit-typographic-motion` can lay real keyed footage over a stage film. A stage declares chroma-keyed clips in `stage/clips/clips.json` (an id, a folder, a frame count and a rate), the frames are 8-bit RGBA PNGs with real transparency, and the page registers each clip once with `LitStage.clip` and calls the returned function from `render(t)`; the frame shown is a plain function of film time, so a cue is `start = cue - (N - 1) / fps` and the flash audit and determinism replay see the final pixels. A declared clip is held to its own caps (12 clips, 360 frames in one and 1500 in all, 40 MB in one and 128 MB in all, 720 px on the long side, 30 fps) and no longer trips the flipbook rule; an undeclared frame folder is still refused with exit 17. Cover mode takes no clips. The manifest and the report list each clip.
6
+
3
7
  ## 0.1.5 — 2026-10-31
4
8
 
5
9
  - `lit-adhd` counts the other sessions and herdr agents at work before it offers this session's own next step. Before, the session that had started them was still told to type `lit continue` (a paused workflow) or `go` (its own start-work run) while they worked, and its own card showed `lit continue`. Now it says to wait for them and keeps `go` silent; a pending approval is still offered, and `lit continue` typed by hand still works.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litmuse",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Installer for LitMuse: skills, hooks and agents that help Meta's Muse Code plan, build, check and hand off work.",
5
5
  "keywords": [
6
6
  "muse",
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "name": "litmuse",
4
4
  "displayName": "LitMuse",
5
- "version": "0.1.5",
5
+ "version": "0.1.6",
6
6
  "description": "LitFamily workflows for Muse Code: bare lit activation, planning, evidence-bound execution, review, research, handoff and Korean prose care.",
7
7
  "compat": {
8
8
  "source": "native",
@@ -57,7 +57,7 @@ State lives in the output folder: `treatment.json`, `look.json`, `stills/`, `man
57
57
  | review only | a critique of an existing render | Open the frames, run `gate <dir>` or `cover-gate <file> --strict`, report. Write nothing. |
58
58
  | blocked | exit 10, 11, 12, 14, 15, 21 or 22 | Stop. Name the state and its fix; never route around it with another pipeline. |
59
59
  | treatment invalid | exit 16 | Fix the named field of `treatment.json` and rerun. |
60
- | stage contract | exit 17, 18 or 19 | Fix the element, API, raster, clock source or URL the message names, and rerun. |
60
+ | stage contract | exit 17, 18 or 19 | Fix the element, API, raster, clip, clock source or URL the message names, and rerun. |
61
61
  | sound invalid | exit 20 | Fix the bed or the supplied track the message names. |
62
62
 
63
63
  ## Procedure
@@ -136,7 +136,7 @@ Stop at a blocked exit, at a gate failure you cannot fix within round 3, or when
136
136
  | 14 | BLOCKED_DEPS_NOT_PREWARMED | Ask the user to run `npx @litfamily/litmuse runtime install motion` in a terminal outside Muse. Never install from the session. |
137
137
  | 15 | BLOCKED_FONT_FETCH | A cached font is missing or its sha256 differs; the same pre-warm command fixes it. |
138
138
  | 16 | BLOCKED_TREATMENT_INVALID | Fix the named field. |
139
- | 17 | STAGE_CONTRACT_ERROR | Fix the named element, API, raster, size or missing copy line. |
139
+ | 17 | STAGE_CONTRACT_ERROR | Fix the named element, API, raster, clip, size or missing copy line. |
140
140
  | 18 | STAGE_NONDETERMINISTIC | The page read something the clock does not drive; seed it or derive it from `t`. |
141
141
  | 19 | STAGE_NETWORK_REQUEST | Remove the named URL or connection; everything is local. |
142
142
  | 20 | SOUND_INVALID | The planned sound is missing, silent at the start or out of limits; fix it. |
@@ -187,7 +187,7 @@ function prepare({ out, env, log }) {
187
187
  const treatment = asBlocked(() => loadCoverTreatment(out));
188
188
  const stageDir = path.join(out, COVER_STAGE);
189
189
  if (!existsSync(path.join(stageDir, "index.html"))) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${path.join(stageDir, "index.html")} is missing; write the cover page first (references/cover.md)`);
190
- const scan = scanStage(stageDir);
190
+ const scan = scanStage(stageDir, { clips: false });
191
191
  for (const w of scan.warnings) log(`warning: ${w}`);
192
192
  const { assets } = checkAssets({ out, treatment, rasters: scan.rasters, coverDir: COVER_STAGE });
193
193
  return { treatment, stageDir, assets, faces: stageFonts(env) };
@@ -107,6 +107,7 @@ export function formatStageReport({ gate, manifest, outputs, round, framesViewed
107
107
  `outputs: ${outputs.film} · ${outputs.preview} · ${outputs.poster} · ${outputs.still}${withheld ? " [WITHHELD: MO-C-03 flash audit failed; exports are diagnostics in withheld/, not deliverables]" : ""}`,
108
108
  `format / duration / fps: ${manifest.format} ${manifest.width}x${manifest.height}, ${manifest.durationSec} s (treatment ${manifest.treatmentDurationSec} s) @ ${manifest.fps} fps, ${manifest.frameCount} frames`,
109
109
  `renderer: ${manifest.renderer} (software rung, stage flags: ${manifest.chromeFlags.join(" ")})`,
110
+ ...(manifest.clips?.length ? [`clips: ${manifest.clips.map((c) => `${c.id} ${c.frames} frames at ${c.fps} fps`).join(", ")} (keyed overlays, picked from film time)`] : []),
110
111
  `sound: ${manifest.sound?.mode ?? "none"}${manifest.sound?.label ? ` (${manifest.sound.label})` : ""}`,
111
112
  "",
112
113
  `QA gate: ${gate.pass ? "PASS" : "FAIL"}`,
@@ -8,6 +8,7 @@ import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { EXIT } from "../core/constants.mjs";
10
10
  import { BlockedError } from "./chrome.mjs";
11
+ import { decodePng } from "./png.mjs";
11
12
  import { checkFont, fontLocation, fontsDir, loadFontManifest } from "./runtime.mjs";
12
13
  import { readPlate } from "./blender.mjs";
13
14
 
@@ -29,6 +30,13 @@ export const CONTENT_TYPES = Object.freeze({
29
30
  });
30
31
  export const RASTER = Object.freeze([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
31
32
  export const RASTER_LIMITS = Object.freeze({ count: 24, bytes: 8 * 1024 * 1024, flipbook: 10 });
33
+ // Keyed overlay clips (references/stage.md, Clips). A frame folder that stage/clips/clips.json declares is held
34
+ // to these caps and not to RASTER_LIMITS: a clip is a sticker laid on the film, never the film itself, so its
35
+ // frames are 8-bit RGBA PNGs with real transparency, at most `side` px on the long side, at most `fps` per second.
36
+ export const CLIP_LIMITS = Object.freeze({
37
+ clips: 12, framesPerClip: 360, framesTotal: 1500, bytesPerClip: 40 * 1024 * 1024, bytesTotal: 128 * 1024 * 1024, side: 720, fps: 30,
38
+ });
39
+ export const CLIPS_DIR = "clips";
32
40
 
33
41
  // The product's verified faces as CSS families. Archivo's widths are one family at three stretches.
34
42
  const FAMILIES = [
@@ -108,7 +116,7 @@ function pngInfo(b) {
108
116
  if (type === "IDAT" || type === "IEND") break;
109
117
  o += 12 + len;
110
118
  }
111
- return { width: b.readUInt32BE(16), height: b.readUInt32BE(20), animated };
119
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20), animated, bitDepth: b[24], colorType: b[25] };
112
120
  }
113
121
  function jpegInfo(b) {
114
122
  if (b[0] !== 0xff || b[1] !== 0xd8) return null;
@@ -166,6 +174,98 @@ export function rasterInfo(file) {
166
174
  return null;
167
175
  }
168
176
 
177
+ // ---- declared clips: stage/clips/clips.json and the frame folders it names ----
178
+ const clipFail = (message) => new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${message}`);
179
+ const clipFrame = (n) => `frame_${String(n).padStart(4, "0")}.png`;
180
+ const CLIP_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/u;
181
+ const CLIP_DIR = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u;
182
+
183
+ /** The clips stage/clips/clips.json declares ([] when there is no file), checked for shape only. Exit 17 on a bad one. */
184
+ export function declaredClips(stageDir, limits = CLIP_LIMITS) {
185
+ const file = path.join(stageDir, CLIPS_DIR, "clips.json");
186
+ if (!existsSync(file)) return [];
187
+ let doc;
188
+ try {
189
+ doc = JSON.parse(readFileSync(file, "utf8"));
190
+ } catch (error) {
191
+ throw clipFail(`${CLIPS_DIR}/clips.json is not valid JSON (${error.message})`);
192
+ }
193
+ if (!doc || !Array.isArray(doc.clips)) throw clipFail(`${CLIPS_DIR}/clips.json needs { "clips": [ { "id", "dir", "count", "fps" } ] }`);
194
+ if (doc.clips.length > limits.clips) throw clipFail(`${doc.clips.length} clips declared (at most ${limits.clips} clips)`);
195
+ const ids = new Set();
196
+ const dirs = new Set();
197
+ return doc.clips.map((c, n) => {
198
+ const at = `${CLIPS_DIR}/clips.json clip #${n + 1}`;
199
+ if (!c || typeof c !== "object") throw clipFail(`${at} is not an object`);
200
+ if (typeof c.id !== "string" || !CLIP_ID.test(c.id)) throw clipFail(`${at}: id must be a plain token of letters, digits, - and _ (got ${JSON.stringify(c.id)})`);
201
+ if (ids.has(c.id)) throw clipFail(`${at}: duplicate id ${c.id}`);
202
+ ids.add(c.id);
203
+ if (typeof c.dir !== "string" || !CLIP_DIR.test(c.dir) || c.dir.includes("..")) throw clipFail(`clip "${c.id}": dir must be a folder name directly under ${CLIPS_DIR}/ (got ${JSON.stringify(c.dir)})`);
204
+ if (dirs.has(c.dir)) throw clipFail(`clip "${c.id}": dir ${c.dir} is already declared by another clip`);
205
+ dirs.add(c.dir);
206
+ if (!Number.isInteger(c.count) || c.count < 1) throw clipFail(`clip "${c.id}": count must be a whole number of frames (got ${JSON.stringify(c.count)})`);
207
+ if (!Number.isInteger(c.fps) || c.fps < 1 || c.fps > limits.fps) throw clipFail(`clip "${c.id}": fps must be a whole number from 1 to ${limits.fps} (got ${JSON.stringify(c.fps)})`);
208
+ return { id: c.id, dir: c.dir, count: c.count, fps: c.fps };
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Check every declared clip against its bytes: exactly frame_0001.png..frame_<count>.png in clips/<dir>/, each a
214
+ * still 8-bit RGBA PNG of one size within the overlay cap, real transparency in the first, middle and last frame,
215
+ * then the per-clip and total caps. Returns [{ id, dir, count, fps, width, height, bytes, frames }]; `frames`
216
+ * are stage-relative paths.
217
+ */
218
+ export function scanClips(stageDir, { limits = CLIP_LIMITS, files = listStage(stageDir) } = {}) {
219
+ const decls = declaredClips(stageDir, limits);
220
+ let totalFrames = 0;
221
+ let totalBytes = 0;
222
+ const clips = decls.map((d) => {
223
+ const prefix = `${CLIPS_DIR}${path.sep}${d.dir}${path.sep}`;
224
+ if (d.count > limits.framesPerClip) throw clipFail(`clip "${d.id}": ${d.count} frames (at most ${limits.framesPerClip} per clip)`);
225
+ const have = new Map(files.filter((f) => f.rel.startsWith(prefix) && RASTER.includes(path.extname(f.rel).toLowerCase())).map((f) => [f.rel.slice(prefix.length), f]));
226
+ const frames = [];
227
+ for (let n = 1; n <= d.count; n++) {
228
+ const f = have.get(clipFrame(n));
229
+ if (!f) throw clipFail(`clip "${d.id}": ${CLIPS_DIR}/${d.dir}/${clipFrame(n)} is missing (declared ${d.count} frames, named frame_0001.png onward)`);
230
+ frames.push(f);
231
+ }
232
+ if (have.size !== d.count) {
233
+ const named = new Set(frames.map((f) => f.rel.slice(prefix.length)));
234
+ const stray = [...have.keys()].find((name) => !named.has(name));
235
+ throw clipFail(`clip "${d.id}": ${CLIPS_DIR}/${d.dir}/${stray} is not one of the ${d.count} declared frames; raise count or remove it`);
236
+ }
237
+ let size = null;
238
+ for (const f of frames) {
239
+ const info = rasterInfo(f.real);
240
+ if (!info) throw clipFail(`clip "${d.id}": ${f.rel} is not a readable PNG`);
241
+ if (info.animated) throw clipFail(`clip "${d.id}": ${f.rel} is an animated image; a clip is a folder of still frames`);
242
+ if (info.colorType !== 6 || info.bitDepth !== 8) throw clipFail(`clip "${d.id}": ${f.rel} must be an 8-bit RGBA PNG with an alpha channel (a keyed frame), not an opaque image`);
243
+ if (Math.max(info.width, info.height) > limits.side) throw clipFail(`clip "${d.id}": ${f.rel} is ${info.width}x${info.height}; a clip is an overlay of at most ${limits.side} px on the long side`);
244
+ if (size && (size.width !== info.width || size.height !== info.height)) throw clipFail(`clip "${d.id}": ${f.rel} is ${info.width}x${info.height} but frame_0001.png is ${size.width}x${size.height}; one size per clip`);
245
+ size = { width: info.width, height: info.height };
246
+ }
247
+ const bytes = frames.reduce((sum, f) => sum + f.bytes, 0);
248
+ if (bytes > limits.bytesPerClip) throw clipFail(`clip "${d.id}": ${(bytes / 1048576).toFixed(1)} MB (at most ${(limits.bytesPerClip / 1048576).toFixed(0)} MB per clip)`);
249
+ for (const f of [frames[0], frames[Math.floor((frames.length - 1) / 2)], frames[frames.length - 1]]) {
250
+ let pixels;
251
+ try {
252
+ ({ pixels } = decodePng(readFileSync(f.real)));
253
+ } catch (error) {
254
+ throw clipFail(`clip "${d.id}": ${f.rel} cannot be decoded (${error.message}); use a non-interlaced 8-bit RGBA PNG`);
255
+ }
256
+ let hasAlpha = false;
257
+ for (let i = 3; i < pixels.length && !hasAlpha; i += 4) hasAlpha = pixels[i] < 255;
258
+ if (!hasAlpha) throw clipFail(`clip "${d.id}": ${f.rel} has no transparent pixel; a clip is keyed, not a full-frame video`);
259
+ }
260
+ totalFrames += d.count;
261
+ totalBytes += bytes;
262
+ return { ...d, ...size, bytes, frames: frames.map((f) => f.rel) };
263
+ });
264
+ if (totalFrames > limits.framesTotal) throw clipFail(`${totalFrames} clip frames in all (at most ${limits.framesTotal})`);
265
+ if (totalBytes > limits.bytesTotal) throw clipFail(`${(totalBytes / 1048576).toFixed(1)} MB of clip frames in all (at most ${(limits.bytesTotal / 1048576).toFixed(0)} MB)`);
266
+ return clips;
267
+ }
268
+
169
269
  // ---- the static pre-flight scan ----
170
270
  const TEXT = new Set([".html", ".js", ".mjs", ".css", ".svg", ".json"]);
171
271
  const NAMESPACES = /^http:\/\/www\.w3\.org\/(?:2000\/svg|1999\/xlink|1999\/xhtml|XML\/1998\/namespace|2000\/xmlns\/?)$/u;
@@ -184,9 +284,11 @@ const NETWORK_API = /\bnew\s+(WebSocket|WebTransport|RTCPeerConnection|EventSour
184
284
 
185
285
  /**
186
286
  * Scan the stage dir before launch. Throws exit 17 (contract) or 19 (network) naming the file and
187
- * the thing found; returns { files, rasters, warnings, plate } otherwise (plate: the Blender plate, or null).
287
+ * the thing found; returns { files, rasters, warnings, plate, clips } otherwise (plate: the Blender plate, or null;
288
+ * clips: the declared keyed clips as scanClips checked them, whose frames are not page rasters). `clips: false`
289
+ * (cover mode) ignores clips.json, so a declared folder is held to the raster limits there.
188
290
  */
189
- export function scanStage(stageDir) {
291
+ export function scanStage(stageDir, { clips: allowClips = true } = {}) {
190
292
  if (!existsSync(path.join(stageDir, "index.html"))) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${path.join(stageDir, "index.html")} is missing; write the stage page first (references/stage.md)`);
191
293
  const files = listStage(stageDir);
192
294
  const warnings = [];
@@ -196,6 +298,8 @@ export function scanStage(stageDir) {
196
298
  const plate = readPlate(stageDir, "plate");
197
299
  const preview = readPlate(stageDir, "plate-preview");
198
300
  const listed = new Set([plate, preview].filter(Boolean).flatMap((p) => p.frames.map((f) => path.join(p.dir, f.name))));
301
+ const clips = allowClips ? scanClips(stageDir, { files }) : [];
302
+ for (const c of clips) for (const rel of c.frames) listed.add(rel);
199
303
  for (const f of files) {
200
304
  const ext = path.extname(f.rel).toLowerCase();
201
305
  if (!CONTENT_TYPES[ext] && f.rel !== "scene.py") warnings.push(`${f.rel}: not a servable type; the page cannot load it`);
@@ -235,7 +339,24 @@ export function scanStage(stageDir) {
235
339
  for (const [size, names] of bySize) {
236
340
  if (names.length >= RASTER_LIMITS.flipbook) throw new BlockedError(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${names.length} rasters of ${size} look like a flipbook; animate the page, not a frame sequence`);
237
341
  }
238
- return { files, rasters, warnings, plate };
342
+ return { files, rasters, warnings, plate, clips };
343
+ }
344
+
345
+ /**
346
+ * window.LitClips for LitStage.clip: id -> { src, count, fps, width, height }, built from clips.json and the first
347
+ * frame's header. null when the stage declares no clips (or one the scan would refuse).
348
+ */
349
+ function clipsScript(stageDir) {
350
+ let table;
351
+ try {
352
+ table = Object.fromEntries(declaredClips(stageDir).map((c) => {
353
+ const info = rasterInfo(path.join(stageDir, CLIPS_DIR, c.dir, clipFrame(1)));
354
+ return [c.id, { src: `${CLIPS_DIR}/${c.dir}`, count: c.count, fps: c.fps, width: info.width, height: info.height }];
355
+ }));
356
+ } catch {
357
+ return null;
358
+ }
359
+ return Object.keys(table).length ? `window.LitClips = ${JSON.stringify(table)};\n` : null;
239
360
  }
240
361
 
241
362
  /**
@@ -243,6 +364,7 @@ export function scanStage(stageDir) {
243
364
  * type } | { kind: "blocked", reason } | { kind: "missing" }, and records every refusal.
244
365
  */
245
366
  export function stageRouter({ stageDir, faces, style = null }) {
367
+ const clipsJs = clipsScript(stageDir);
246
368
  const root = realpathSync(stageDir);
247
369
  const css = fontsCss(faces);
248
370
  const byRoute = new Map(faces.map((f) => [f.route, f.file]));
@@ -266,6 +388,7 @@ export function stageRouter({ stageDir, faces, style = null }) {
266
388
  if (pathname === "/lit/stage-kit.js") return { kind: "file", file: KIT_FILE, type: CONTENT_TYPES[".js"] };
267
389
  if (pathname === "/lit/loop-kit.js") return { kind: "file", file: LOOP_KIT_FILE, type: CONTENT_TYPES[".js"] };
268
390
  if (pathname === "/lit/fonts.css") return { kind: "body", body: css, type: CONTENT_TYPES[".css"] };
391
+ if (clipsJs && pathname === "/lit/clips.js") return { kind: "body", body: clipsJs, type: CONTENT_TYPES[".js"] };
269
392
  if (pathname.startsWith("/lit/three/")) {
270
393
  const rel = pathname.slice("/lit/three/".length);
271
394
  const file = path.join(THREE_DIR, rel);
@@ -480,6 +480,7 @@ export async function renderStage({ out, round = 1, stillsOnly = false, env = pr
480
480
  frameCount, seed: treatment.seed, renderer: finalState.webglRenderer ?? "no WebGL", rung: ctx.rung, chromeFlags: master.flags, usesWebGL: finalState.usesWebGL,
481
481
  cuts, stills: { sha256: stills.sha256, poster: plan.poster, reduced: plan.reduced }, posterEncoding: poster,
482
482
  geometry: geometryFor(width, height),
483
+ clips: scan.clips.map((c) => ({ id: c.id, frames: c.count, fps: c.fps, width: c.width, height: c.height, bytes: c.bytes })),
483
484
  plate: scan.plate ? { blender: scan.plate.blender, engine: scan.plate.engine, device: scan.plate.device, samples: scan.plate.samples, fps: scan.plate.fps, frames: scan.plate.frames.length } : null,
484
485
  sound: { mode: sound.mode, label: sound.label, file: sound.file ? path.relative(out, sound.file) : null, sha256: sound.file ? sha256(readFileSync(sound.file)) : null, lufs: sound.bed?.lufs ?? null },
485
486
  preview: { encoder: preview.encoder, rung: preview.rung, attempts: preview.attempts },
@@ -677,6 +677,45 @@
677
677
  }
678
678
  const wipe = (el, p, opts) => clipRect(el, p, opts);
679
679
 
680
+ // A declared keyed clip (stage/clips/clips.json, served as window.LitClips by /lit/clips.js). One wrapper sits at
681
+ // `box` over the film and holds the clip's frame for t: frame floor((t - start) * fps) + 1, counted from `from`
682
+ // and kept within from..to. Nothing reads a real clock and no <video> is involved, so the frame is a pure
683
+ // function of film time. Before `start` and past `to` the wrapper is hidden and show returns null, unless
684
+ // `hold` keeps the last frame or `loop` wraps by frame index. The wrapper is what a page scales or fades.
685
+ function clip({ parent, id, box } = {}) {
686
+ const all = root.LitClips;
687
+ if (!all) throw new Error('LitStage.clip: load <script src="/lit/clips.js"></script> after the kit; this stage declares no clips (clips/clips.json)');
688
+ const c = all[id];
689
+ if (!c) throw new Error(`LitStage.clip: no clip "${id}" in clips/clips.json (declared: ${Object.keys(all).join(", ")})`);
690
+ if (!parent || !box || !(box.w > 0) || !(box.h > 0)) throw new Error("LitStage.clip: needs { parent, id, box: { x, y, w, h } }");
691
+ const wrap = document.createElement("div");
692
+ wrap.setAttribute("data-lit-clip", id);
693
+ 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" });
694
+ parent.append(wrap);
695
+ let shown = 0;
696
+ let img = null;
697
+ return (t, { start = 0, from = 1, to = c.count, loop = false, hold = false } = {}) => {
698
+ if (!(from >= 1 && to <= c.count && from <= to)) throw new Error(`LitStage.clip: from..to must lie within 1..${c.count} (got ${from}..${to})`);
699
+ const n = to - from + 1;
700
+ const k = Math.floor((t - start) * c.fps + 1e-6);
701
+ const i = k < 0 ? 0 : k < n ? from + k : loop ? from + (k % n) : hold ? to : 0;
702
+ wrap.style.display = i ? "block" : "none";
703
+ if (!i) return null;
704
+ if (i !== shown) {
705
+ // A new <img> per change: init.js decodes every img in the document before a frame is captured.
706
+ const next = document.createElement("img");
707
+ next.src = `${c.src}/frame_${String(i).padStart(4, "0")}.png`;
708
+ next.alt = "";
709
+ Object.assign(next.style, { display: "block", width: "100%", height: "100%", objectFit: "contain" });
710
+ if (img) img.replaceWith(next);
711
+ else wrap.append(next);
712
+ img = next;
713
+ shown = i;
714
+ }
715
+ return wrap;
716
+ };
717
+ }
718
+
680
719
  // ---- the page contract ----
681
720
  const FORMATS = [[1920, 1080], [1080, 1920]];
682
721
  function define(spec) {
@@ -697,5 +736,6 @@
697
736
  clipRect, clipCircle, maskLinear, wipe,
698
737
  mix, rgba,
699
738
  clamp, lerp, invLerp, remap, smoothstep,
739
+ clip,
700
740
  });
701
741
  })(typeof window !== "undefined" ? window : globalThis);
@@ -15,7 +15,7 @@ The page lives at `<dir>/stage/index.html`, next to the local files it uses (SVG
15
15
  CSS, WAV). Only that folder is served, under the origin `http://lit.stage`; a symlink that resolves
16
16
  outside it is a contract error.
17
17
 
18
- Four routes come from the renderer itself and are always available:
18
+ These routes come from the renderer itself (the first three bullets always, the last only with declared clips):
19
19
 
20
20
  - `<link rel="stylesheet" href="/lit/fonts.css">` declares the verified faces (table below).
21
21
  - `<script src="/lit/stage-kit.js"></script>` defines the global `LitStage`.
@@ -23,6 +23,8 @@ Four routes come from the renderer itself and are always available:
23
23
  film's house style for its format: custom properties, the type-role and colour classes, `window.LitStyle`
24
24
  (tokens and pixel zones) and `window.LitTimeline` (the treatment's shots). Set copy with the classes;
25
25
  the gate checks the rendered result either way.
26
+ - `<script src="/lit/clips.js"></script>` defines `window.LitClips` when the stage declares keyed clips
27
+ (Clips); without a declaration the route does not exist.
26
28
 
27
29
  The page hands the renderer its spec with `LitStage.define({ width, height, fps, duration, render })`, or
28
30
  by assigning the same object to `window.litStage`. `define` checks the spec as soon as it is called:
@@ -102,6 +104,10 @@ along an angle.
102
104
  `rgba(colour, alpha)` sets alpha. `clamp`, `lerp`, `invLerp`, `remap(x, a, b, c, d)` and
103
105
  `smoothstep(a, b, x)` cover the rest.
104
106
 
107
+ **Clips.** `clip({ parent, id, box })` registers a declared keyed clip once and returns a `show(t, opts)`
108
+ function to call from `render(t)` (see Clips): `const boom = clip({ parent, id: "boom", box: { x: 1200, y: 300,
109
+ w: 520, h: 511 } }); boom(t, { start: 2.1 });`.
110
+
105
111
  **Registration.** `text(el, { decor })` marks an element as a text run; `text({ content, x, y, w, h })`
106
112
  registers canvas or WebGL text for the frame being drawn (see Text and the QA).
107
113
 
@@ -124,6 +130,46 @@ A family outside this list falls back to a system face; the text QA reports it (
124
130
  when the run carries copy. Ask only for weights the family has, since the browser would fake a bold
125
131
  otherwise, and never animate letter-spacing or width on Hangul.
126
132
 
133
+ ## Clips
134
+
135
+ Real keyed footage (a reaction clip, a spinning object shot on a green screen) can join the film as a
136
+ layer of the page. The footage is keyed outside the renderer into one 8-bit RGBA PNG per frame, the frames
137
+ are declared, and the page picks the frame from film time. Nothing is encoded by hand: the frames are
138
+ captured with the rest of the page, so the flash audit, the determinism replay and the text checks run on the
139
+ final pixels as for any other film. A declared clip is held to its own caps below; every other raster keeps
140
+ the limits under "What a page may not contain".
141
+
142
+ 1. Put each clip's frames in the run's stage folder as `stage/clips/<dir>/frame_0001.png`,
143
+ `frame_0002.png` and so on, with no gap and no other image in that folder.
144
+ 2. Declare them in `stage/clips/clips.json`:
145
+
146
+ ```json
147
+ { "clips": [ { "id": "boom", "dir": "01-vine-boom", "count": 122, "fps": 30 } ] }
148
+ ```
149
+
150
+ `id` is the name the page uses, `dir` a folder directly under `clips/`, `count` the number of frames
151
+ and `fps` the clip's own rate (1 to 30). A clip used twice is declared once.
152
+ 3. The page loads `<script src="/lit/clips.js"></script>` after the kit, calls
153
+ `LitStage.clip({ parent, id, box })` once per use, and calls the returned `show(t, opts)` inside
154
+ `render(t)`. `parent` is the element the clip sits in and `box` its place in pixels; the frame is fitted
155
+ inside it. `show` returns the clip's wrapper element, or `null` while the clip is hidden, so a page can
156
+ scale, fade or move the wrapper from `t` like any other layer.
157
+
158
+ The frame shown is `floor((t - start) * fps) + 1`, counted from `from`. A cue is therefore plain
159
+ arithmetic: a clip whose own sound begins at frame N and must land on a cue at `cue` seconds has
160
+ `start = cue - (N - 1) / fps`. Options: `start` (film seconds, default 0), `from` and `to` (the frame range,
161
+ default the whole clip), `loop` (wrap by frame index) and `hold` (keep the last frame). Before `start`, and
162
+ past `to` without `loop` or `hold`, the clip is hidden.
163
+
164
+ The scan refuses, with exit 17 and the clip named: more than 12 clips; more than 360 frames in a clip or
165
+ 1500 in all; more than 40 MB in a clip or 128 MB in all; a frame over 720 px on its long side (a clip is
166
+ an overlay, not the film); a rate above 30 fps; frames of mixed sizes; a missing or extra frame; a frame
167
+ that is not an 8-bit RGBA PNG, or that has no transparent pixel in the first, middle and last frame (an
168
+ opaque video is not keyed); an animated image; and a duplicate `id`. A frame folder that is not declared,
169
+ and any frame sequence beside a declared clip, is still a flipbook. Cover mode takes no clips. The text
170
+ checks read DOM text only, so keep clips clear of copy and check the stills. The manifest and the report
171
+ list each clip.
172
+
127
173
  ## Who owns time
128
174
 
129
175
  The renderer does. An init script installed before the page loads replaces the wall clock with a virtual
@@ -151,7 +197,8 @@ the first differing 64×64 region. Variation comes from `LitStage.rand(seed)`.
151
197
  150 KB or carrying a `@license` banner is flagged as a likely library in the warnings.
152
198
  - Too many or animated rasters. At most 24 raster files (PNG, JPG, WebP, GIF) and 8 MB in total; ten or
153
199
  more rasters of the same pixel size are treated as a flipbook; any animated GIF, APNG or WebP is refused.
154
- All are exit 17. Rasters are textures and stills inside a drawn scene.
200
+ All are exit 17. Rasters are textures and stills inside a drawn scene; the one frame sequence a page may
201
+ carry is a declared keyed clip (Clips).
155
202
  - A film made some other way. A frame loop encoded by hand, a screen recording or an image sequence pushed
156
203
  through ffmpeg is not the deliverable, whatever it looks like. Every frame goes through this renderer.
157
204