@litfamily/litopencode 1.0.36 โ†’ 1.0.38

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/dist/state.d.ts +1 -0
  5. package/dist/state.js +1 -0
  6. package/dist/tools.d.ts +15 -5
  7. package/dist/tui/mission-band.d.ts +7 -0
  8. package/dist/tui/mission-band.js +4 -0
  9. package/dist/tui/mission-skills.js +3 -1
  10. package/dist/tui/mission-source-v2.d.ts +23 -0
  11. package/dist/tui/mission-source-v2.js +29 -4
  12. package/dist/tui/mission-source.d.ts +1 -0
  13. package/dist/tui/mission-source.js +3 -2
  14. package/dist/tui/mission-text.d.ts +3 -3
  15. package/dist/tui/mission-text.js +5 -5
  16. package/dist/v2/events.d.ts +1 -1
  17. package/dist/v2/events.js +31 -5
  18. package/dist/v2/notices.d.ts +13 -0
  19. package/dist/v2/notices.js +143 -0
  20. package/dist/v2/tui-footer.d.ts +20 -0
  21. package/dist/v2/tui-footer.js +56 -0
  22. package/dist/v2/tui-notices.d.ts +7 -0
  23. package/dist/v2/tui-notices.js +45 -0
  24. package/dist/v2/tui.d.ts +43 -1
  25. package/dist/v2/tui.js +345 -32
  26. package/dist/v2/usage-limits.d.ts +19 -0
  27. package/dist/v2/usage-limits.js +70 -0
  28. package/docs/assets/readme/badge-version.svg +1 -1
  29. package/docs/reference-Ko-KR.md +24 -5
  30. package/docs/reference.md +29 -7
  31. package/package.json +6 -2
  32. package/skills/lit-typographic-motion/engine/cover/render.mjs +1 -1
  33. package/skills/lit-typographic-motion/engine/stage/capture.mjs +3 -2
  34. package/skills/lit-typographic-motion/engine/stage/gate.mjs +1 -0
  35. package/skills/lit-typographic-motion/engine/stage/render.mjs +1 -0
  36. package/skills/lit-typographic-motion/engine/stage/scan.mjs +109 -5
  37. package/skills/lit-typographic-motion/engine/stage/stage-kit.js +39 -1
  38. package/skills/lit-typographic-motion/references/cover.md +2 -1
  39. package/skills/lit-typographic-motion/references/runtime.md +1 -1
  40. package/skills/lit-typographic-motion/references/stage.md +48 -2
  41. package/skills/lit-typographic-motion/requirements-audio.txt +92 -89
  42. package/skills/managed-skill-manifest.json +13 -13
  43. package/skills/readme-studio/templates/hyperframes/package-lock.json +4 -4
  44. package/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  45. package/skills/readme-studio/templates/remotion/package.json +1 -1
package/docs/reference.md CHANGED
@@ -498,12 +498,34 @@ settings but does not send them yet.
498
498
  TUI: the line under the prompt (`prompt.footer.status`) reads `๐Ÿ”ฅ LIT IGNITED ยท <discipline>` while the
499
499
  latest prompt of the session is a lit prompt. On OpenCode 1 the mark stays where it was.
500
500
  - *Toasts.* The server cannot show toasts on OpenCode 2. The TUI raises the ignition toast itself when a
501
- user message arrives with the lit mark the server writes into its metadata. The automatic-handoff
502
- notices and the skill hint, which only the server knew about, are not shown there.
503
- - *TUI.* The mission panel sits in the session sidebar as before. OpenCode 2 passes no options to a TUI
504
- that comes with a server plugin, and it keeps no todo list, so mission progress comes from the ledger,
505
- the work record and the plan. The status band under the session and the home-screen line are drawn on
506
- OpenCode 1 only.
501
+ user message arrives with the lit mark the server writes into its metadata, and it raises the same gate
502
+ toasts as OpenCode 1 (plan saved, goal set, criterion passed, blockers, complete) plus three more that
503
+ only OpenCode 2 has: the workflow stage moving (`๐Ÿ”ฅ Stage`, `work 3/7`), a switch of discipline
504
+ (`๐Ÿ”ฅ lit-loop โ†’ start-work`) and a lane starting or finishing. A burst folds into one toast with a
505
+ `+N more` tail. The restart notice, the `lit adhd` suggestion and the opt-in finish alert work as on
506
+ OpenCode 1. The automatic-handoff notices, which only the server knew about, reach the screen through
507
+ `.litopencode/notices.jsonl`: the OpenCode 2 server plugin can only subscribe to events and has no way to
508
+ raise a toast, so it appends each notice there and the TUI plugin shows the lines that appear after it
509
+ started (the file is capped and never follows a symbolic link). The one popup the server raised besides
510
+ those, the ignition toast, is already the TUI's own, so there is no separate skill hint left to port.
511
+ - *TUI.* The HUD is placed in the slots OpenCode 2 publishes: the status card is claimed in `app`, under
512
+ the session and the sidebar, and keeps its four rows idle or working; the stage chip shares
513
+ `prompt.footer.status` with the ignition line, because there is no slot right of the agent and model
514
+ row; the home line is in `home.footer`; the mission panel sits in `sidebar.content`, so it shows when
515
+ the sidebar is open. The card, chip, panel and home line use the same text builders as OpenCode 1. The
516
+ `band` and `notify` options of the plugin entry are read from the options OpenCode 2 hands over. The footer
517
+ line steps down with the terminal width instead of being clipped by the host: mark plus the whole chip,
518
+ mark plus a smaller chip, the chip alone, the chip as `๐Ÿ”ฅ 1/3`, the bare flame, then nothing; the room is the
519
+ width less the sidebar (assumed open above 120 columns) and the host's own spinner and figures. OpenCode 2
520
+ keeps no todo list, so mission progress comes from the ledger, the work record and the plan. It names
521
+ its tools differently (`shell`, `subagent`, a `skill` tool that takes `id`), and the card reads those
522
+ names. OpenCode 2 gives plugins one usage source, `client.usage.limits()`: the OpenCode Go allowance of the
523
+ Console workspace the location is signed in to (a rolling five-hour window, a weekly one and a monthly
524
+ one). For a session whose latest answer came from the `opencode-go` provider the card's bottom border shows
525
+ `5h 42% ยท 1w 18%` after the context figure (the monthly window only from 90%, and a window the host reports
526
+ as refusing requests reads `limited`). It asks at most once a minute. For any other provider, a signed-out
527
+ host (the reply is `null`) or an error, nothing is drawn; OpenCode 2 exposes no provider rate-limit headers or
528
+ events to a plugin, so there is no 5-hour or weekly figure for the Anthropic or OpenAI plans.
507
529
  - *Updates.* There is no automatic self-update inside the plugin on OpenCode 2. Run
508
530
  `opencode plugin update`, or let OpenCode 2's own update policy handle it.
509
531
 
@@ -586,7 +608,7 @@ What it draws:
586
608
  the colour wheel at one fixed lightness, four small colour steps a second with no flashing. It
587
609
  is off under `motion: false` and `NO_COLOR`. An idle session in a workspace with earlier work
588
610
  says `lit adhd to pick up where you left off`, and the first fresh session there raises one
589
- toast with the same words (v1 TUI only; the v2 TUI has no band and no toast).
611
+ toast with the same words (OpenCode 1 and 2).
590
612
  - one look at a time, picked from a small registry in `src/tui/mission-looks.ts`. Every route that runs
591
613
  lit-adhd wears the rewind look: `lit adhd`/`lit rewind` in chat, `/lit-adhd`/`/lit-rewind` (also from an
592
614
  installed v2 alias file) and a native `skill` load. While the session waits on you (a permission or a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litopencode",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
4
4
  "description": "Workflow plugin for OpenCode: add lit to a prompt and the work is planned, built, checked and written down so the next session can pick it up.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "yaml": "^2.9.0",
73
- "zod": "4.1.8"
73
+ "zod": "4.6.6"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@opencode-ai/plugin": "^1.17.6",
@@ -79,6 +79,10 @@
79
79
  "picomatch": "^4.0.5",
80
80
  "typescript": "^5.9.3"
81
81
  },
82
+ "overrides": {
83
+ "zod": "$zod",
84
+ "toml": "5.0.1"
85
+ },
82
86
  "publishConfig": {
83
87
  "access": "public"
84
88
  }
@@ -108,7 +108,7 @@ function prepare(out) {
108
108
  const cover = loadCoverTreatment(out);
109
109
  const coverDir = path.join(out, "cover");
110
110
  if (!existsSync(path.join(coverDir, "index.html"))) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `no cover page at ${path.join(coverDir, "index.html")}; write it first (references/cover.md)`);
111
- const scan = scanStage(coverDir);
111
+ const scan = scanStage(coverDir, { clips: false });
112
112
  const assets = checkAssets({ out, treatment: cover, rasters: scan.rasters });
113
113
  const state = inspectWarmState();
114
114
  if (state.blocked) throw stop(state.blocked.exit, state.blocked.name, `${state.blocked.detail}; fix: ${state.fix}`);
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { chromeFlagRungs } from "../constants.mjs";
9
- import { StageError, contentTypes, insideDir } from "./scan.mjs";
9
+ import { StageError, clipsScript, contentTypes, insideDir } from "./scan.mjs";
10
10
 
11
11
  export const stageHost = "lit.stage";
12
12
  export const stageOrigin = `http://${stageHost}`;
@@ -108,7 +108,8 @@ export async function openStage(chromium, { chromePath, profile, stageDir, width
108
108
  const line = String(error?.message ?? error).split("\n").find((part) => part.trim()) ?? String(error);
109
109
  throw Object.assign(new Error(`BLOCKED_NO_CHROME (10): Chrome failed to launch headless: ${line}; fix: check that Chrome can start headless in this sandbox`), { exitCode: 10 });
110
110
  }
111
- const { blocked, refused, missing } = await serveStage(context, { stageDir, fonts, scripts: { "/lit/stage-kit.js": kitSource(), ...styleFiles } });
111
+ const clips = clipsScript(stageDir);
112
+ const { blocked, refused, missing } = await serveStage(context, { stageDir, fonts, scripts: { "/lit/stage-kit.js": kitSource(), ...(clips ? { "/lit/clips.js": clips } : {}), ...styleFiles } });
112
113
  await context.addInitScript({ content: `window.__litStageConfig = ${JSON.stringify({ seed: seed >>> 0, fps })};\n${clockSource()}` });
113
114
  if (extraInit) await context.addInitScript({ content: extraInit });
114
115
  const page = context.pages()[0] ?? await context.newPage();
@@ -146,6 +146,7 @@ export function formatStageReport(result, context) {
146
146
  `outputs: ${out("film.mp4")} ยท ${out(manifest.previewEncoder === "gif" ? "preview.gif" : "preview.webp")} (encoder: ${manifest.previewEncoder}) ยท ${out("poster.png")} ยท ${out("reduced-motion.png")}`,
147
147
  `format / fps / length: ${manifest.format} ${manifest.resolution.join("x")} @ ${manifest.fps} fps, ${manifest.durationSec.toFixed(2)} s (treatment ${manifest.targetDurationSec} s)`,
148
148
  `renderer: stage ${manifest.rung === "gpu" ? `GPU rung (${manifest.renderer ?? "native ANGLE"})` : "software rung"} (flags: ${(manifest.chromeFlags ?? []).join(" ")})`,
149
+ ...(manifest.clips?.length ? [`clips: ${manifest.clips.map((clip) => `${clip.id} ${clip.frames} frames at ${clip.fps} fps`).join(", ")} (keyed overlays, chosen from film time)`] : []),
149
150
  ...(manifest.warnings ?? []).map((note) => `notice: ${note}`),
150
151
  `timing: master ${manifest.timing?.masterSec ?? "?"} s, per frame p50 ${manifest.timing?.frameP50 ?? "?"} ms / p95 ${manifest.timing?.frameP95 ?? "?"} ms, total ${manifest.timing?.totalSec ?? "?"} s`,
151
152
  "",
@@ -450,6 +450,7 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
450
450
  preview: preview ? { width: preview.width, height: preview.height, fps: preview.fps, bytes: preview.bytes } : null,
451
451
  posterFrame, posterSize: [poster.width, poster.height], stillFrame, determinismFrames: samples,
452
452
  timing: { ...master.timing, totalSec: Number(((performance.now() - began) / 1000).toFixed(1)) },
453
+ ...(context.scan.clips.length ? { clips: context.scan.clips.map((clip) => ({ id: clip.id, frames: clip.count, fps: clip.fps, width: clip.width, height: clip.height, bytes: clip.bytes })) } : {}),
453
454
  ...(context.plate ? { plate: { blender: context.plate.blender, engine: context.plate.engine, device: context.plate.device, samples: context.plate.samples, fps: context.plate.fps, frames: context.plate.frames.length } } : {}),
454
455
  sound, qa: { file: ".run/qa.json", samples: qaResult.samples.length, trackFrames: qaResult.trackFrames, allowedFaces: qaResult.allowedFaces },
455
456
  treatmentSha256: sha256(readFileSync(path.join(out, "treatment.json"))), stageSha256: stageSha(context.stageDir),
@@ -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