@litfamily/litgrok 1.0.29 → 1.0.31

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 (28) hide show
  1. package/.grok/rules/00-litgrok.md +13 -0
  2. package/.grok/skills/lit-adhd/SKILL.md +10 -4
  3. package/.grok/skills/lit-adhd/scripts/lib/brief.mjs +4 -1
  4. package/.grok/skills/lit-adhd/scripts/lib/collect.mjs +14 -1
  5. package/.grok/skills/lit-adhd/scripts/lib/next-step.mjs +46 -5
  6. package/.grok/skills/lit-init/SKILL.md +4 -0
  7. package/.grok/skills/lit-typographic-motion/references/stage.md +50 -0
  8. package/.grok/skills/lit-typographic-motion/requirements-audio.txt +92 -89
  9. package/.grok/skills/lit-typographic-motion/scripts/lib/clips.mjs +119 -0
  10. package/.grok/skills/lit-typographic-motion/scripts/lib/constants.mjs +13 -0
  11. package/.grok/skills/lit-typographic-motion/scripts/lib/cover.mjs +1 -1
  12. package/.grok/skills/lit-typographic-motion/scripts/lib/stage-gate.mjs +1 -0
  13. package/.grok/skills/lit-typographic-motion/scripts/lib/stage-session.mjs +3 -0
  14. package/.grok/skills/lit-typographic-motion/scripts/lib/stage.mjs +13 -4
  15. package/.grok/skills/lit-typographic-motion/scripts/stage/stage-kit.js +44 -0
  16. package/.grok/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  17. package/.grok/skills/readme-studio/templates/remotion/package.json +3 -0
  18. package/.grok-plugin/plugin.json +1 -1
  19. package/CHANGELOG.md +11 -0
  20. package/README.md +8 -8
  21. package/README_ko-KR.md +8 -8
  22. package/bin/ground-rule.mjs +573 -0
  23. package/bin/litgrok.mjs +42 -5
  24. package/docs/assets/readme/badge-version.svg +1 -1
  25. package/docs/reference.md +2 -2
  26. package/docs/reference_ko-KR.md +2 -2
  27. package/package.json +1 -1
  28. package/plugin.json +1 -1
@@ -202,3 +202,16 @@ An old agent name, prefixed or not, selects the renamed agent and prints that ro
202
202
  | `oracle-verifier` | `litgrok-verifier` | Note: `oracle-verifier` was renamed to `litgrok-verifier`; the old name is removed in the next minor. |
203
203
 
204
204
  These rows are prompt guidance, not a router: old slash routes are not guaranteed; never create old skill directories or invent command metadata.
205
+
206
+ <!-- litfamily:litgrok:start v=1.0.31 pre=0a -->
207
+ ## LitGrok ground rules
208
+
209
+ Grok Build reads this file on every turn. These lines are the working agreement. The sections above still choose the skill.
210
+
211
+ - A prompt that starts with `lit` is LitGrok's activation word.
212
+ - Plans live in `plans/`. The session handoff lives in `.handoff/HANDOFF.md`, or in `HANDOFF.md` outside a Git repo. Run notes live in `.grok/litgrok/`. Read the handoff and the active plan before resuming.
213
+ - Never push, publish, tag or bump a version unless the user explicitly says yes to that action.
214
+ - Call work done only after its tests pass and the changed surface was checked for real.
215
+ - In a new repo, an approved lit-init places the short project rule by running `litgrok ground project`.
216
+ - Turn these lines off with `litgrok ground off`. `litgrok ground on` brings them back.
217
+ <!-- litfamily:litgrok:end -->
@@ -101,7 +101,7 @@ The collector (`scripts/lib/collect.mjs`, served as `scripts/rewind.mjs`) reads,
101
101
 
102
102
  Plans and handoffs are read first, so a run whose transcripts spend the budget still shows what is left. Session files are read head (64 KiB) and tail (256 KiB) only, under one 8 MiB budget for the whole run, through descriptors opened without following links. The store format is private and may move, so every field is read defensively and anything missing reads as unknown. Only the session's own project folder is ever listed: the store is never scanned blindly. Every string is redacted before it leaves the collector: credential shapes, `NAME=value` with a secret-like name, URI userinfo, any current environment value of eight or more characters, and the home directory (shown as `~`).
103
103
 
104
- Grok Build keeps no litgoal ledger, workflow stack or start-work state file, so those sources stay `unknown` and the offered next step degrades to plans. A paused workflow or a start-work boundary shows in the waiting reason only when the transcript itself says so.
104
+ Grok Build keeps no litgoal ledger, workflow stack or start-work state file, so those lists stay empty unless a record already carries a frame or a boundary. A paused workflow or a start-work boundary also shows in the waiting reason when the transcript itself says so.
105
105
 
106
106
  ## Status taxonomy
107
107
 
@@ -116,10 +116,16 @@ Sessions waiting on the user come first, then active, idle, done and ended ones,
116
116
 
117
117
  ## The next step
118
118
 
119
- `next` is chosen by code, in this priority order, and the brief offers exactly that one:
119
+ `next` is chosen by code, in this priority order, and the brief offers exactly that one. Other work is counted first: another live session, or a herdr agent that is working or blocked in another pane. While that work runs, this session is not told to `lit continue` or `go`, and its own card does not offer the resume command. An approval is still offered.
120
120
 
121
- 1. Exactly one open plan in this worktree and no work running: start it. Say `go`.
122
- 2. Otherwise `none`: say so.
121
+ 1. This session's paused frame, and no other work running: resume it. Say `lit continue`.
122
+ 2. A start-work boundary waiting on the user: say `lit approve`. It is not automatic.
123
+ 3. Other work still running: `none`. The card says to leave this session until it finishes.
124
+ 4. This session's own running start-work: continue it. Say `go`.
125
+ 5. Exactly one open plan in this worktree and nothing else running: start it. Say `go`. With several open, the one saved in the last 30 minutes wins.
126
+ 6. Otherwise `none`: say so.
127
+
128
+ Grok Build keeps no workflow stack on disk. The collector leaves those lists empty unless a record supplies them. Herdr's agent list is read when `HERDR_ENV=1`.
123
129
 
124
130
  `go` (also `lit go`, `ㄱㄱ`, `고`, `진행`, as the whole prompt) continues the step the brief offered: re-collect first, then continue the step the fresh brief offers, which the live records derive the same way again. The collector writes no session record, so there is nothing to go stale. A start-work boundary approval still needs the typed words (`lit approve` / `lit deny`) and is never automatic. Anything else typed after the brief is an ordinary prompt.
125
131
 
@@ -46,6 +46,7 @@ const WORDS = {
46
46
  self: "this session",
47
47
  answer: (q) => `answer its question: “${q}”`,
48
48
  resume: (skill, stage, self) => `resume ${skill} at ${stage} (\`lit continue\`${self ? "" : " there"})`,
49
+ lanesAtWork: (n) => `${n} other ${n === 1 ? "piece of work is" : "pieces of work are"} still running. Leave this session until ${n === 1 ? "it finishes" : "they finish"}.`,
49
50
  approve: "approve or deny the start-work boundary (`lit approve` / `lit deny`)",
50
51
  planNow: (plan, item) => `${plan} → ${item}`,
51
52
  nothing: "nothing pending on record",
@@ -115,6 +116,7 @@ const WORDS = {
115
116
  self: "이 세션",
116
117
  answer: (q) => `질문에 답하기: “${q}”`,
117
118
  resume: (skill, stage, self) => `${skill}(${stage}) 이어가기 (${self ? "" : "그 세션에서 "}\`lit continue\`)`,
119
+ lanesAtWork: (n) => `다른 작업 ${n}건이 아직 진행 중입니다. 끝날 때까지 이 세션은 이어가지 않습니다.`,
118
120
  approve: "start-work 경계 승인 또는 거절 (`lit approve` / `lit deny`)",
119
121
  planNow: (plan, item) => `${plan} → ${item}`,
120
122
  nothing: "기록된 남은 일 없음",
@@ -218,6 +220,7 @@ const actionOf = (session, escape = plain) => {
218
220
  const nextOf = (session, data, w) => {
219
221
  const frames = (data.workflows ?? []).filter((frame) => frame.id8 === session.id8);
220
222
  const paused = frames.find((frame) => frame.status === "paused");
223
+ if (paused && session.self && data.next?.wait?.lanes) return w.lanesAtWork(data.next.wait.lanes);
221
224
  if (paused) return w.resume(paused.skill, paused.stage, session.self);
222
225
  if (session.waitingReason === "start-work boundary") return w.approve;
223
226
  if (session.status === "waiting-on-user") {
@@ -287,7 +290,7 @@ const suggestion = (data, w) => {
287
290
  if (waiting) return w.suggest.answer({ id8: waiting.id8, where: where(waiting), question: lastQuestion(waiting.result, bare) });
288
291
  const paused = (data.workflows ?? []).find((frame) => frame.status === "paused");
289
292
  const owner = paused ? sessions.find((session) => session.id8 === paused.id8) : null;
290
- if (paused) return w.suggest.resume({ id8: paused.id8, skill: paused.skill, stage: paused.stage, where: owner ? where(owner) : basename(String(paused.worktree)) });
293
+ if (paused && !data.next?.wait?.lanes) return w.suggest.resume({ id8: paused.id8, skill: paused.skill, stage: paused.stage, where: owner ? where(owner) : basename(String(paused.worktree)) });
291
294
  // A plan in the worktree of a live session comes first, then this worktree's, then the newest.
292
295
  const liveTrees = new Set(sessions.filter((session) => session.status !== "ended").map((session) => session.worktree));
293
296
  const open = (data.roadmap?.plans ?? []).filter((item) => item.current && !item.complete);
@@ -189,11 +189,24 @@ const commitsOf = (root, sinceMs, now, exec, redact) => {
189
189
  }
190
190
  };
191
191
 
192
+ const herdrAgent = (agent, env, redact) => ({
193
+ status: typeof agent?.agent_status === "string" ? agent.agent_status : "unknown",
194
+ pane: typeof agent?.pane_id === "string" ? clipToWidth(redact(agent.pane_id), 40) : "unknown",
195
+ id8: typeof agent?.agent_session?.value === "string" ? agent.agent_session.value.slice(0, 8) : null,
196
+ self: Boolean(env.HERDR_PANE_ID) && agent?.pane_id === env.HERDR_PANE_ID,
197
+ });
198
+
192
199
  const herdrOf = (env, exec, redact) => {
193
200
  if (env.HERDR_ENV !== "1") return { status: "unknown", agents: [] };
194
201
  try {
195
202
  const out = exec("herdr", ["agent", "list"], { timeout: HERDR_TIMEOUT_MS });
196
- return { status: "ok", agents: out.split("\n").filter((line) => line.trim()).slice(0, 20).map((line) => clipToWidth(redact(line.trim()), 120)) };
203
+ try {
204
+ const listed = JSON.parse(out)?.result?.agents;
205
+ if (!Array.isArray(listed)) throw new Error("not a list");
206
+ return { status: "ok", agents: listed.slice(0, 20).map((agent) => herdrAgent(agent, env, redact)) };
207
+ } catch {
208
+ return { status: "ok", agents: out.split("\n").filter((line) => line.trim()).slice(0, 20).map((line) => clipToWidth(redact(line.trim()), 120)) };
209
+ }
197
210
  } catch {
198
211
  return { status: "unknown", agents: [] };
199
212
  }
@@ -1,16 +1,57 @@
1
1
  // The one next step a lit-adhd brief offers, chosen by a fixed priority over the collected
2
- // records:
3
- // 1. an open plan with no work running -> start-plan (`go`)
2
+ // records. Other work is counted first. While it is still running, this session is not told to
3
+ // resume itself or to continue on `go`. An approval is still offered: that decision is the user's.
4
+ // 1. this session's paused frame, and no other work running -> resume (`lit continue`)
5
+ // 2. a start-work boundary waiting on the user -> approve (`lit approve`, not automatic)
6
+ // 3. other work still running -> none, with `wait`
7
+ // 4. this session's own running start-work -> continue (`go`)
8
+ // 5. one open plan and nothing else running -> start-plan (`go`)
4
9
  // (with several open, the one plan saved in the last 30 minutes wins)
5
- // 2. otherwise -> none
6
- // A paused start-work boundary is shown as the session's waiting reason, never as an automatic
7
- // step: Grok Build has no hook that can consume a typed approval, so the model reads the box.
10
+ // 6. otherwise -> none
8
11
  // `go` re-collects the brief and continues the offered step: no record is written, because the
9
12
  // model has no documented way to learn its session id and no hook could hand a record back to it.
10
13
 
11
14
  const JUST_SAVED_MS = 30 * 60_000;
12
15
 
16
+ // Another live session, or a herdr agent that is working or blocked and is not this pane.
17
+ const busyLanes = (data) => {
18
+ const lanes = new Set((data.sessions ?? [])
19
+ .filter((session) => !session.self && (session.status === "active" || session.status === "waiting-on-user"))
20
+ .map((session) => session.id8));
21
+ for (const [index, agent] of (data.herdr?.agents ?? []).entries()) {
22
+ if (!agent || typeof agent === "string" || agent.self) continue;
23
+ if (agent.status === "working" || agent.status === "blocked") lanes.add(agent.id8 || agent.pane || `herdr-${index}`);
24
+ }
25
+ return lanes.size;
26
+ };
27
+
28
+ const selfId8 = (data, sessionId) => {
29
+ const self = (data.sessions ?? []).find((session) => session.self);
30
+ if (self?.id8) return self.id8;
31
+ return typeof sessionId === "string" ? sessionId.slice(0, 8) : null;
32
+ };
33
+
34
+ const isOwn = (item, sessionId, id8) => Boolean(item) && (item.sessionId === sessionId || item.id8 === id8 || item.id8 === sessionId);
35
+
13
36
  export const chooseNextStep = (data, sessionId) => {
37
+ const lanes = busyLanes(data);
38
+ const id8 = selfId8(data, sessionId);
39
+ const ownPaused = (data.workflows ?? []).find((frame) => frame.status === "paused" && isOwn(frame, sessionId, id8));
40
+ if (sessionId && ownPaused && !lanes) {
41
+ return { kind: "resume-frame", target: { skill: ownPaused.skill, stage: ownPaused.stage }, say: "lit continue" };
42
+ }
43
+ const approval = (data.startWork ?? []).find((work) => work.pendingBoundary);
44
+ if (approval) {
45
+ return {
46
+ kind: "approve-boundary",
47
+ target: { plan: approval.plan, ...approval.pendingBoundary },
48
+ say: "lit approve",
49
+ automatic: false,
50
+ };
51
+ }
52
+ if (lanes) return { kind: "none", target: null, wait: { lanes } };
53
+ const ownRun = (data.startWork ?? []).find((work) => work.status === "active" && sessionId && isOwn(work, sessionId, id8));
54
+ if (ownRun) return { kind: "continue-work", target: { plan: ownRun.plan }, say: "go" };
14
55
  const open = data.roadmap.plans.filter((plan) => plan.local && plan.progress.total !== null && plan.progress.done < plan.progress.total);
15
56
  const running = (data.workflows ?? []).some((frame) => frame.status === "active" || frame.status === "paused")
16
57
  || data.sessions.some((session) => session.status === "active" || session.status === "waiting-on-user");
@@ -177,6 +177,10 @@ The internal analysis should contain:
177
177
  Keep proposed wording separate from observed current wording.
178
178
  Label every new policy choice that needs user approval.
179
179
 
180
+ ## Project ground rules
181
+
182
+ When the user approves the exact files to create, and this agreement is not already in the repo, place it by running `litgrok ground project` with the repository root as the working directory. The command writes one project rule and no other file. That rule keeps plans in the plans directory, the handoff beside the repo or in a handoff directory, and run notes in LitGrok's state directory, out of commits. A push, publish, tag or version bump waits for an explicit yes. It refuses a symlink, a path that is not a directory, or an existing file it does not own. Do not write that rule by hand, and do not run the command before the user approves that target. If the command refuses, stop and report the path it printed.
183
+
180
184
  ## Mutation gate
181
185
 
182
186
  Do not create an instruction file merely because none exists.
@@ -15,6 +15,7 @@ Allowed files: html, js, mjs, css, svg, png, jpg, webp, gif, wav, woff, woff2, t
15
15
  stage directory.
16
16
  - Rasters are textures and stills, never a frame sequence: at most 24 raster images and 8 MB in
17
17
  total. Ten or more rasters of one size (a flipbook), or any animated GIF, APNG or WebP, is exit 17.
18
+ The one frame sequence a page may carry is a declared keyed clip (Clips).
18
19
  - Forbidden, exit 17 naming it: `<video>`, `<audio>`, `<iframe>`, `<object>`, `<embed>`, `<frame>`,
19
20
  `new Audio()`, `AudioContext`, `OfflineAudioContext`, Worker, SharedWorker, service workers,
20
21
  WebSocket, WebTransport, RTCPeerConnection.
@@ -25,6 +26,8 @@ Allowed files: html, js, mjs, css, svg, png, jpg, webp, gif, wav, woff, woff2, t
25
26
  <script src="/lit/stage-kit.js"></script>
26
27
  ```
27
28
 
29
+ A page with clips also loads `<script src="/lit/clips.js"></script>` (Clips).
30
+
28
31
  `/lit/fonts.css` declares the verified faces with `font-display: block`: `Archivo` (weights 400,
29
32
  700, 900; `font-stretch` 75 %, 100 %, 125 %), `Pretendard` (Hangul, 400 and 700), `VT323`,
30
33
  `Silkscreen` (400, 700), `Galmuri9` and `Meslo` (both need the pre-warm), and `JetBrains Mono`
@@ -88,11 +91,58 @@ LitStage.drawPath(svgPath, p); // stroke draw-on
88
91
  const shape = LitStage.morph(dA, dB); path.setAttribute('d', shape(p)); // single-subpath morph
89
92
  LitStage.clip(el, p, { from: 'left' }); // inset reveal; LitStage.circle(el, p, { x, y })
90
93
  const c = LitStage.mix('#0b1f33', '#7fd1ff', p); // colour mix
94
+ const show = LitStage.overlay({ parent, id, box }); // a declared keyed clip (Clips); show(t, { start })
91
95
  ```
92
96
 
93
97
  `morph` normalizes both paths to cubics, resamples them by arc length and picks the best rotation;
94
98
  paths with more than one subpath are not supported (split them into separate paths).
95
99
 
100
+ ## Clips
101
+
102
+ Real keyed footage (a reaction clip, a spinning-cat loop shot on green screen) joins the film as a
103
+ layer inside the page. Key the video outside the renderer into one 8-bit RGBA PNG per frame, declare
104
+ the frames, and the page picks the frame from film time. A declared clip leaves the flipbook and
105
+ count rule; every other raster keeps its limits. Nothing is encoded by hand: the page is captured
106
+ frame by frame as usual, so the flash audit, the determinism replay and the text checks run on the
107
+ final pixels.
108
+
109
+ 1. Copy each clip's frames into the run's stage folder as `stage/clips/<dir>/frame_0001.png`,
110
+ `frame_0002.png` and so on, with no gaps and no other image in the folder.
111
+ 2. Declare them in `stage/clips/clips.json`:
112
+
113
+ ```json
114
+ { "clips": [ { "id": "boom", "dir": "01-vine-boom", "count": 122, "fps": 30 } ] }
115
+ ```
116
+
117
+ `id` is the name the page uses, `dir` a folder directly under `clips/`, `count` the number of
118
+ frames and `fps` the clip's own rate, 1 to 30. A clip used twice is declared once.
119
+ 3. The page loads `<script src="/lit/clips.js"></script>` after the kit, registers each use once with
120
+ `LitStage.overlay({ parent, id, box })` and calls the returned `show(t, opts)` from `render(t)`.
121
+ (`LitStage.clip` is the inset reveal; it has nothing to do with clips.) `parent` is the element
122
+ the clip sits in; `box` is its place in pixels, the frame fitted inside it. `show` returns the
123
+ clip's wrapper element, or `null` while hidden; scale, fade or move that wrapper from `t` like any
124
+ other layer.
125
+
126
+ ```js
127
+ const boom = LitStage.overlay({ parent: stageEl, id: 'boom', box: { x: 1200, y: 300, w: 520, h: 511 } });
128
+ render(t) { boom(t, { start: 2.1 }); }
129
+ ```
130
+
131
+ The frame is `floor((t - start) * fps) + 1`, counted from `from`, so a cue is plain arithmetic: a
132
+ clip whose own sound starts at frame 85 and must land on a cue at 0.12 s has
133
+ `start = 0.12 - 84 / 30`. Options: `start` (film seconds, default 0), `from` and `to` (the frame
134
+ range, default the whole clip), `loop` (wrap by frame index) and `hold` (keep the last frame).
135
+ Before `start` and past `to`, without `loop` or `hold`, the clip is hidden.
136
+
137
+ The scan refuses, with exit 17 and the clip named: more than 12 clips, 360 frames in a clip or 1500
138
+ in all, 40 MB in a clip or 128 MB in all, a frame whose long side is above 720 px (a clip is an
139
+ overlay, not the film), an fps above 30, frames of mixed sizes, a missing or extra frame, a frame
140
+ that is not an 8-bit RGBA PNG or has no transparent pixel in the first, middle and last frame (an
141
+ opaque video is not keyed), an animated image, and a duplicate `id`. A folder that is not declared,
142
+ or any frame sequence beside a declared clip, is a flipbook as before. Cover mode takes no clips.
143
+ Keep clips clear of copy: the text checks read DOM text, so a clip laid over a line is yours to
144
+ catch on the stills. The manifest and the gate report list each clip.
145
+
96
146
  ## Serving and network
97
147
 
98
148
  The page is served from the synthetic origin `http://lit.stage/` with no listening socket. Only
@@ -392,95 +392,98 @@ llvmlite==0.49.0 \
392
392
  --hash=sha256:ee81e96c15a6f870918f1eb60c913551c16aa23defb4f5f1acfa660d6a0aaac2 \
393
393
  --hash=sha256:f3f2ff0aeb17d34fcce9f79b99baac441cfd3efa41b83e233ca4530a72381f72
394
394
  # via numba
395
- msgpack==1.2.2 \
396
- --hash=sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516 \
397
- --hash=sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9 \
398
- --hash=sha256:0883a1578168929fd1640fbbc4614773f1a130e419a8a817dc2918d9af1b651c \
399
- --hash=sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3 \
400
- --hash=sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77 \
401
- --hash=sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4 \
402
- --hash=sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f \
403
- --hash=sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986 \
404
- --hash=sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f \
405
- --hash=sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc \
406
- --hash=sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc \
407
- --hash=sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34 \
408
- --hash=sha256:1f3af0baafd184436501004828bb3df64eeb2fc49dfe9d89abcf604956094563 \
409
- --hash=sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c \
410
- --hash=sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a \
411
- --hash=sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808 \
412
- --hash=sha256:336525cc2688e43ea77dfb1a4ce012c8cde561835913801dbfcfdcf4111d8abb \
413
- --hash=sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b \
414
- --hash=sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782 \
415
- --hash=sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367 \
416
- --hash=sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e \
417
- --hash=sha256:42fd9260416885b4815caca5bdd14dfd5dda6cdade732d6c09104ef8f6228761 \
418
- --hash=sha256:46ec851571d8f1b6e29794ebb9dd36f785008da6d14f57c702e60781d6caf648 \
419
- --hash=sha256:4710d881d8fb047deed2485707409116722af2b992d3fefd73c7667c4e350839 \
420
- --hash=sha256:4955accbd87f27beebef5f3ecc27503aa74cb016fb4f640868e749fd93194a35 \
421
- --hash=sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc \
422
- --hash=sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3 \
423
- --hash=sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab \
424
- --hash=sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23 \
425
- --hash=sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84 \
426
- --hash=sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7 \
427
- --hash=sha256:58ce37a4a54577115922385d37201d9a44d66d0167dfbbf4770a2e9bf8ea7ba3 \
428
- --hash=sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640 \
429
- --hash=sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5 \
430
- --hash=sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839 \
431
- --hash=sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b \
432
- --hash=sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695 \
433
- --hash=sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d \
434
- --hash=sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f \
435
- --hash=sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923 \
436
- --hash=sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8 \
437
- --hash=sha256:7afa5431f6f3487c584187ca6c8e2a34e9b106529893b3e720eabb068f6ac970 \
438
- --hash=sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3 \
439
- --hash=sha256:7fe374ba76eb0ecca13a1703daa8fa85825a6ddddbb52d4c1a732fa524194683 \
440
- --hash=sha256:82b1bdf293267afaadcc608b125e7fc6576bb0785a60c4fa7d07c7ab76ed76ec \
441
- --hash=sha256:86f173a584f72f6164801f31866d22a581f60c991572cf922aed9ab8eb422b77 \
442
- --hash=sha256:8b1415d02e9bf722672af8a90f90813265a0cd0b14163187261e54a5592bc949 \
443
- --hash=sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533 \
444
- --hash=sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc \
445
- --hash=sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe \
446
- --hash=sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba \
447
- --hash=sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff \
448
- --hash=sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36 \
449
- --hash=sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5 \
450
- --hash=sha256:9bd3d1557c3fe1a095068210708a03e3e4795973392af6f4047060e70abd9a6c \
451
- --hash=sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929 \
452
- --hash=sha256:9d7fb25b4442fae0cb2590272d06ab4f6caa526ee36a994edb81e946b874813e \
453
- --hash=sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b \
454
- --hash=sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a \
455
- --hash=sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5 \
456
- --hash=sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754 \
457
- --hash=sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd \
458
- --hash=sha256:a9b4cf3685a135666d27d0d7a73fece74e2fad01d9b508fded89e843512f0e90 \
459
- --hash=sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea \
460
- --hash=sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f \
461
- --hash=sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212 \
462
- --hash=sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3 \
463
- --hash=sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21 \
464
- --hash=sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3 \
465
- --hash=sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54 \
466
- --hash=sha256:b9b0c1f2aa7b0026b4bd50718100e8b04175e4f36e160aa852502377b5e572e7 \
467
- --hash=sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc \
468
- --hash=sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b \
469
- --hash=sha256:cdb6cc6e1127d15879c47a8b3270716243da82d3e7feab1f5946872c75b3d60f \
470
- --hash=sha256:cf66fb38703e61a486b01b56d43bb1f50698fbe99b6bd90feba10f24fab60b3b \
471
- --hash=sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896 \
472
- --hash=sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248 \
473
- --hash=sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9 \
474
- --hash=sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b \
475
- --hash=sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8 \
476
- --hash=sha256:e05a94a0442de86818a30281c6cc2cb9cc7aa148386fd3541c4d4774b73cb3a9 \
477
- --hash=sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5 \
478
- --hash=sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf \
479
- --hash=sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04 \
480
- --hash=sha256:f11e09f10210a91c169e39c7a5a1f9090eaa73ad75555fafad5023c3053c47ba \
481
- --hash=sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650 \
482
- --hash=sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f \
483
- --hash=sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22
395
+ msgpack==1.2.3 \
396
+ --hash=sha256:07c9733089d1b176c3dd2f7fa268452f9d5d784d076473499d754a58e8d1fbbb \
397
+ --hash=sha256:0955b9000725573d1457c1676944b370dd9643c8d18f25bda5ac72913f850949 \
398
+ --hash=sha256:0c91762c48cd686dc9cf2b142c0bc544083952de32f5853d6624c956e54b85e5 \
399
+ --hash=sha256:0ed5823c4efc20fe87d3530665f40ec18a002be003114814c21235cc8d256207 \
400
+ --hash=sha256:13221a6c81ebb8e43ea63a7251c35d54e4175cea37ebf3a62e911bdf42562a3c \
401
+ --hash=sha256:186e6c602b8a9968b8e864c67d622a69279f7d1e55ae25f40e3bff7e815b2b62 \
402
+ --hash=sha256:18a6ed513023001b28dcd3ba54966f6bb90a38274ba8d2640464bcab3a1b81d4 \
403
+ --hash=sha256:1d6bcec3dbbdb89ca385d3a73e63ceae7b841fa0d7ca7c676f1a7bfe7fb2cdb8 \
404
+ --hash=sha256:1f4ae8bd4ad9ba085fde95e95d055a896d19210238a4199a771a3cf36dceed49 \
405
+ --hash=sha256:1f585407f740a9eac04a3bb82c61d68a0ea78f90e29e670bfb086b9ce3a518dd \
406
+ --hash=sha256:21bfa4d2aa0b04c1806ef778a1199e9e53ea2441bcbf284420a32083896320b8 \
407
+ --hash=sha256:2487453ca1b6104442c6442f9a1a8fee1fe8f428a70d99d4cba799108b304150 \
408
+ --hash=sha256:2574ef81c1c8c38b10e330f3f9406fd09198a776b002030fafcf8e7647e9e06e \
409
+ --hash=sha256:30e1522e4173230dca4d9ad896f038f73c0da6c1edd42f4dbad88ac583cf5d46 \
410
+ --hash=sha256:32edb81a2b5eb7cd7c9d941b2bfbbb082fd2cd09e0e725930316af6b708db186 \
411
+ --hash=sha256:3372475211a9ce1a23acefe512cb3e121d18c95dc74ed56cb1819ef40836ebf4 \
412
+ --hash=sha256:382b219de3d436de3baba0f4b0c6d4336e8f5858d0eb047918b13b69a71c6c55 \
413
+ --hash=sha256:382bc88fe90f29f5ac8a0b65c7046ff255356f2f2f3186c30e370215736fa1dc \
414
+ --hash=sha256:39b6986c19e1f2dfa549d185dba6ccf1de2e4c0ba10d8cfc0048935b1c5f9109 \
415
+ --hash=sha256:3a31905206722103a84c1f72633fe30692cff6732c9d262e09a27dbc468797c8 \
416
+ --hash=sha256:3d4c807ed050fe3ddbea5ba7e9f63d7136871ce42861be1f50ff739f0e91047a \
417
+ --hash=sha256:3ec409b0d6aa8e9eec6eaf881b893caa215dbe68c5319ca96e8a271d81bb111d \
418
+ --hash=sha256:471e12a6a42498a31490c206e0069e343b6a7c35db540be73a879eb06f5be047 \
419
+ --hash=sha256:4c0780095871ecc49a58b2ff6b1b43b25214704da67646557ca287a3f49fb2dd \
420
+ --hash=sha256:59612b4ed48a04cf024584218e813562f3b30a3bafa5f55abe300b15da314751 \
421
+ --hash=sha256:5bd5f91ea75c45cafcc5433ba8fae59b708b736ec178d2441c40c499e9e079db \
422
+ --hash=sha256:5bf390259cb25a6a1cd197c65810999b811f64cd38683251538bcc5a1e41f7d3 \
423
+ --hash=sha256:5c1efdd9181cb1b719ee46865f368a927f1c0c65d577798340b1194545b7515a \
424
+ --hash=sha256:5e0d7950ca3c1bbae291d0552dd3bb2792fc680629c4c0d44e47e5bab969f3ca \
425
+ --hash=sha256:5f304123b90e8b2e49867981b7f6061612c39f50cca51ee88de007c084cf68d3 \
426
+ --hash=sha256:62cc1a4ef0e553bac32c8342e1f04834aca7de276b92744eb7307db77759b890 \
427
+ --hash=sha256:63bb7448a1e9111319ae2430c09a5596140c160422830d6271bc75730ff2ff9a \
428
+ --hash=sha256:6576f348ed6cc4f31db6fd915a8e94245f042f50eae08d48732425e70638ea37 \
429
+ --hash=sha256:666ef5601ab0e6e345e47febc96aa81143cc932201543480cbb9499164f05ffb \
430
+ --hash=sha256:6707d2fa2aa1bb5424ea0b05f44ffc989b15ab41a73ff5855bff4944fec7c8ac \
431
+ --hash=sha256:69ad12cedb674c73527bed869cddb42b742cac79a207a614202a4abaa24ea173 \
432
+ --hash=sha256:6a834097144aabe948b8ca9020a833e8026f7d0abbd0ec54bc7e50f45a8ce012 \
433
+ --hash=sha256:6df430419f2338cb71e4a34d6e64f83c88ccd321f91f40ba4513400b36d864ec \
434
+ --hash=sha256:700bc0fc9e968a292b9137ee70e7a012f7e115bf0107ce45e3a88202788dfc1e \
435
+ --hash=sha256:7013534a7163aa4f213c4d9864f1a8a7555daac6fcd48f699a198e29b436bfab \
436
+ --hash=sha256:7995a7c6a62a1d6e7df211b4a16de513bd99fd053525050a319f80f44fb8015e \
437
+ --hash=sha256:79dfa38faf92f804aa61beec140d70b18418e1dde1778dbb77a87a4cce85aa8a \
438
+ --hash=sha256:7a003b02c6ee2eea6dfe0bb08818631e3597e69f0131f2a8250488a1cc553290 \
439
+ --hash=sha256:7c047250096f9fc19dba26e3d1639b5e7a84114003605c94def667149a70ced1 \
440
+ --hash=sha256:84a6616d396ec1bc18a1e83e67c96a393ec35dfe5e17434a5be7b9aa0fe988ab \
441
+ --hash=sha256:87cf2ef05ff2f2493ba29fcdaef27e960ca64dacfd13460ae29e6f92e0ed05bb \
442
+ --hash=sha256:89c930aece4e972b208ba589c8410b4167b05e411a5ea2cb25fd96f8bc47ee43 \
443
+ --hash=sha256:8ca67f77938ea6a3663aa9bd22b3e031f6da84d665be850abab910ee90728dfd \
444
+ --hash=sha256:8e51eca14fbb65c4e0a5a9657346962bd3dca78c08e04e3d4dee70ef48687d30 \
445
+ --hash=sha256:8ec7a1d49ca6c2569d722ab5ec86e90089b0713900aa31905b47b4c4d9e78ce0 \
446
+ --hash=sha256:902f3490db0e07a7d40b48536a85c9b28fbf1397e7e1658a45a55f958e303620 \
447
+ --hash=sha256:905a189853d6bdb204c7ae5f4ab77fb857448abfff574d3d93c62e2815b24b4f \
448
+ --hash=sha256:9276ba88891338f2617044429dfd080ae008c9868a25f6f1a7d004a35dc9ac0a \
449
+ --hash=sha256:9324c54995641c3d1f92a9d55093c8cde0ffa2fbc87a467a688ef60428393220 \
450
+ --hash=sha256:968583e956d0427878050b371308c5f8647088732ef3e66a117dbe1192ec91e0 \
451
+ --hash=sha256:9d7e9cbb0998bbfd363fd9a09c330520d5e9cb323c05b5a1a05865d23ccf2226 \
452
+ --hash=sha256:a393e428f6ffb0dcb73308c1fff5593041c16ff42da66e5bac8a83a6107a54b0 \
453
+ --hash=sha256:a6b63917d60d6df451f328bd6afba8565e33c4afe1f62ec4ad758b78731c827b \
454
+ --hash=sha256:b1631e12fe572e181cd77e831f69335d6cd5278eac22e3db3f33cf264ac2ac18 \
455
+ --hash=sha256:b774ff994d844e541439ac5d2d49a14def4104830c3465e9394c153f86200ffb \
456
+ --hash=sha256:b949cc25e4a09252cbcc54e66e507de914d0e94a3a7039bd54c299bf7037c098 \
457
+ --hash=sha256:bb89b5dc30469c84bbf8684826eb851d82412ca95690e111b9ac5e8fb343961a \
458
+ --hash=sha256:bfe7d5b62cbe7aa664f0b3e2c49077f10fcdd06183d3014f8271ff3c5edbfbf9 \
459
+ --hash=sha256:c309a7abae1d14ba29a8bd0ddbd704a5e469d8e9bd9c3dee0e4ff53d7ae01d56 \
460
+ --hash=sha256:c77e27790ad72989db783d5303825fba0b71550f00a490efba35cde7dc4b719f \
461
+ --hash=sha256:c942c21a93f36b3a69e828c8945bb72c94dc2ffe488a2086950c812f3edf046c \
462
+ --hash=sha256:ccea05b5542f6d283fef3f0a8e93a7f0be90af0ddeeef84c25c0216ba76dcae1 \
463
+ --hash=sha256:cd5a9f9f86a52c24713679aa2631956835f3842512964ff93f736ff76f1f530d \
464
+ --hash=sha256:d0238cd05dec9ffbe0de1071df685ba63e30a36ac155285b1a094e727c38cbe9 \
465
+ --hash=sha256:d1c1e8989a855b7f1f2a64ec4a80b23a631822903952770813857b2e4f460471 \
466
+ --hash=sha256:d2f9c4f85e47a44d26d5baf3b041eef23436e224d44eed273f01bd8a12048d9f \
467
+ --hash=sha256:d31864ba3933a589b6a00249f89c0eb422197f49128fc10da550e57e9cb0f377 \
468
+ --hash=sha256:d8ef3a66e4b52d2d7fdd90df2984670124b2ff7546d76bb25dcf68ef47f7df58 \
469
+ --hash=sha256:db84203b13aecc222f465061397fdd5b53b7ae73d2c95ffc1c8dc5be0153a709 \
470
+ --hash=sha256:db9fb67a3a2e75247bae569d34ebb5ff61c0448a4f0d6dbf991dae68af39b007 \
471
+ --hash=sha256:e0bd394e999949c814f7912284243298de1b5a17b6a3dcb6cc8a79b156ffc4fa \
472
+ --hash=sha256:e15f70588f4db8cd10df0930145b186de70feb9db51710cd378b1399009655bd \
473
+ --hash=sha256:e54394b7dbe2e12ab032d9d21feef7bb61a90a150a2623633ba3781ba69dcb1f \
474
+ --hash=sha256:eaf7e82249837e3aa97297b34a0bb9ff562027381631e057cea6e1367f10b438 \
475
+ --hash=sha256:ec0030361cc861ac699b2ef1c695b741fa145c88f8667fa3d7e3f73deeb648a3 \
476
+ --hash=sha256:ec90a9ae3e1169fa1171147340f0e97d941aa19fcd3b34e8339a55933ed042af \
477
+ --hash=sha256:ed899d73a22f286a72bd9528d63f2ab3030dbad8bf1527fc249319a50d61fb9d \
478
+ --hash=sha256:ede33b2892ceb976283e009ad12fa1834cfdf1f9c43ee9c97849fc588d00a618 \
479
+ --hash=sha256:f24a43b3560e20f825b807fe1e874bd73d53abaf8bbdcf258a6eb152cddbc1f5 \
480
+ --hash=sha256:f3d7b3d0018746b5997dd6b14a1870b07cc4c327d9101145d94a1fc264a51a06 \
481
+ --hash=sha256:f41ca154b7737b11893cdce3c78c61d703398a1cd54d4297bdad908392338a8e \
482
+ --hash=sha256:f42f146752eedb6765f07dcc04d72dab0a25779ec8d4a88c0085263ce114f22c \
483
+ --hash=sha256:f56fba61b2516be7917cb00151f0d060b5b21184e3499bb57f0f7d9259bea124 \
484
+ --hash=sha256:f9ddd28d3e9bbc602a9dced1591882c7fb9ab776eef8837da2c326fde19e2853 \
485
+ --hash=sha256:fafc3b8898b432b841d30a61082c599fa7f4d06885f9dc58ad72259e12059fa6 \
486
+ --hash=sha256:fcc6800daac4922960f6eeb7a0dda3dd4105e0bf7bce0e83ebc465a78cb7bdba
484
487
  # via librosa
485
488
  narwhals==2.26.0 \
486
489
  --hash=sha256:29326d74f107c347fd1009bd58e38d9f7c7c5b51e6de97bc93dbc325d9038b54 \
@@ -0,0 +1,119 @@
1
+ // Declared keyed overlay clips on the stage path: stage/clips/clips.json names RGBA frame folders,
2
+ // scanClips checks every declared frame against its bytes, and clipsScript writes the table the page
3
+ // reads as window.LitClips (served at /lit/clips.js). See references/stage.md, Clips.
4
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { CLIP_LIMITS } from './constants.mjs';
7
+ import { decodePng } from './png.mjs';
8
+
9
+ export const CLIPS_DIR = 'clips';
10
+ export const clipFrame = (i) => `frame_${String(i).padStart(4, '0')}.png`;
11
+ const ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
12
+ const DIR = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
13
+ const MB = 1024 * 1024;
14
+
15
+ /** Header facts of a PNG: size, bit depth, colour type (6 = RGBA) and whether it is an APNG. */
16
+ function pngHeader(bytes) {
17
+ if (bytes.length < 33 || bytes.toString('latin1', 1, 4) !== 'PNG') return null;
18
+ let animated = false;
19
+ for (let o = 8; o + 8 <= bytes.length;) {
20
+ const type = bytes.toString('latin1', o + 4, o + 8);
21
+ if (type === 'acTL') animated = true;
22
+ if (type === 'IDAT' || type === 'IEND') break;
23
+ o += 12 + bytes.readUInt32BE(o);
24
+ }
25
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), bitDepth: bytes[24], colorType: bytes[25], animated };
26
+ }
27
+
28
+ /** The clips stage/clips/clips.json declares ([] without the file), checked for shape only. Throws Error. */
29
+ export function declaredClips(stageDir, limits = CLIP_LIMITS) {
30
+ const file = join(stageDir, CLIPS_DIR, 'clips.json');
31
+ if (!existsSync(file)) return [];
32
+ let doc;
33
+ try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw new Error(`${CLIPS_DIR}/clips.json is not valid JSON (${error.message})`); }
34
+ if (!doc || !Array.isArray(doc.clips)) throw new Error(`${CLIPS_DIR}/clips.json needs { "clips": [ { "id", "dir", "count", "fps" } ] }`);
35
+ if (doc.clips.length > limits.clips) throw new Error(`${doc.clips.length} clips declared (at most ${limits.clips} clips)`);
36
+ const ids = new Set();
37
+ const dirs = new Set();
38
+ return doc.clips.map((c, n) => {
39
+ const at = `${CLIPS_DIR}/clips.json clip #${n + 1}`;
40
+ if (!c || typeof c !== 'object') throw new Error(`${at} is not an object`);
41
+ if (typeof c.id !== 'string' || !ID.test(c.id)) throw new Error(`${at}: id must be a plain token of letters, digits, - and _ (got ${JSON.stringify(c.id)})`);
42
+ if (ids.has(c.id)) throw new Error(`${at}: duplicate id ${c.id}`);
43
+ ids.add(c.id);
44
+ if (typeof c.dir !== 'string' || !DIR.test(c.dir) || c.dir.includes('..')) throw new Error(`clip "${c.id}": dir must be a folder name directly under ${CLIPS_DIR}/ (got ${JSON.stringify(c.dir)})`);
45
+ if (dirs.has(c.dir)) throw new Error(`clip "${c.id}": dir ${c.dir} is already declared by another clip`);
46
+ dirs.add(c.dir);
47
+ if (!Number.isInteger(c.count) || c.count < 1) throw new Error(`clip "${c.id}": count must be a whole number of frames (got ${JSON.stringify(c.count)})`);
48
+ if (!Number.isInteger(c.fps) || c.fps < 1 || c.fps > limits.fps) throw new Error(`clip "${c.id}": fps must be a whole number from 1 to ${limits.fps} (got ${JSON.stringify(c.fps)})`);
49
+ return { id: c.id, dir: c.dir, count: c.count, fps: c.fps };
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Check every declared clip against its bytes. Each clip needs exactly frame_0001.png..frame_<count>.png in clips/<dir>/, every one an
55
+ * 8-bit still RGBA PNG of one size within the overlay cap, a transparent pixel in the first, middle and
56
+ * last frame, and the per-clip and total caps. Throws Error naming the clip; returns
57
+ * [{ id, dir, count, fps, width, height, bytes, frames }] where `frames` are stage-relative paths.
58
+ */
59
+ export function scanClips(stageDir, { limits = CLIP_LIMITS } = {}) {
60
+ const decls = declaredClips(stageDir, limits);
61
+ let totalFrames = 0;
62
+ let totalBytes = 0;
63
+ const clips = decls.map((d) => {
64
+ if (d.count > limits.framesPerClip) throw new Error(`clip "${d.id}": ${d.count} frames (at most ${limits.framesPerClip} per clip)`);
65
+ const prefix = `${CLIPS_DIR}/${d.dir}/`;
66
+ const folder = join(stageDir, CLIPS_DIR, d.dir);
67
+ const have = new Set(existsSync(folder) ? readdirSync(folder, { withFileTypes: true }).filter((e) => e.isFile() && /\.(png|jpe?g|webp|gif)$/i.test(e.name)).map((e) => e.name) : []);
68
+ const frames = [];
69
+ for (let i = 1; i <= d.count; i += 1) {
70
+ if (!have.has(clipFrame(i))) throw new Error(`clip "${d.id}": ${prefix}${clipFrame(i)} is missing (declared ${d.count} frames, named frame_0001.png onward)`);
71
+ frames.push(`${prefix}${clipFrame(i)}`);
72
+ }
73
+ const stray = [...have].find((name) => !frames.includes(`${prefix}${name}`));
74
+ if (stray) throw new Error(`clip "${d.id}": ${prefix}${stray} is not one of the ${d.count} declared frames; raise count or remove it`);
75
+ let size = null;
76
+ let bytes = 0;
77
+ const read = (rel) => readFileSync(join(stageDir, ...rel.split('/')));
78
+ const headers = frames.map((rel) => {
79
+ const buffer = read(rel);
80
+ bytes += buffer.length;
81
+ const info = pngHeader(buffer);
82
+ if (!info) throw new Error(`clip "${d.id}": ${rel} is not a readable PNG`);
83
+ if (info.animated) throw new Error(`clip "${d.id}": ${rel} is an animated image; a clip is a folder of still frames`);
84
+ if (info.colorType !== 6 || info.bitDepth !== 8) throw new Error(`clip "${d.id}": ${rel} must be an 8-bit RGBA PNG with an alpha channel (a keyed frame), not an opaque image`);
85
+ if (Math.max(info.width, info.height) > limits.side) throw new Error(`clip "${d.id}": ${rel} is ${info.width}x${info.height}; a clip is an overlay of at most ${limits.side} px on the long side`);
86
+ if (size && (size.width !== info.width || size.height !== info.height)) throw new Error(`clip "${d.id}": ${rel} is ${info.width}x${info.height} but frame_0001.png is ${size.width}x${size.height}; one size per clip`);
87
+ size = { width: info.width, height: info.height };
88
+ return buffer;
89
+ });
90
+ if (bytes > limits.bytesPerClip) throw new Error(`clip "${d.id}": ${(bytes / MB).toFixed(1)} MB (at most ${(limits.bytesPerClip / MB).toFixed(0)} MB per clip)`);
91
+ for (const at of [0, Math.floor((frames.length - 1) / 2), frames.length - 1]) {
92
+ let pixels;
93
+ try { ({ pixels } = decodePng(headers[at])); } catch (error) { throw new Error(`clip "${d.id}": ${frames[at]} cannot be decoded (${error.message}); use a non-interlaced 8-bit RGBA PNG`); }
94
+ let keyed = false;
95
+ for (let i = 3; i < pixels.length && !keyed; i += 4) keyed = pixels[i] < 255;
96
+ if (!keyed) throw new Error(`clip "${d.id}": ${frames[at]} has no transparent pixel; a clip is keyed, not a full-frame video`);
97
+ }
98
+ totalFrames += d.count;
99
+ totalBytes += bytes;
100
+ return { ...d, ...size, bytes, frames };
101
+ });
102
+ if (totalFrames > limits.framesTotal) throw new Error(`${totalFrames} clip frames in all (at most ${limits.framesTotal})`);
103
+ if (totalBytes > limits.bytesTotal) throw new Error(`${(totalBytes / MB).toFixed(1)} MB of clip frames in all (at most ${(limits.bytesTotal / MB).toFixed(0)} MB)`);
104
+ return clips;
105
+ }
106
+
107
+ /**
108
+ * The script served at /lit/clips.js: window.LitClips = { id: { src, count, fps, width, height } }, from
109
+ * clips.json and each first frame's header. null when the stage declares no clips, or one the scan refuses.
110
+ */
111
+ export function clipsScript(stageDir) {
112
+ try {
113
+ const table = Object.fromEntries(declaredClips(stageDir).map((c) => {
114
+ const info = pngHeader(readFileSync(join(stageDir, CLIPS_DIR, c.dir, clipFrame(1))));
115
+ return [c.id, { src: `${CLIPS_DIR}/${c.dir}`, count: c.count, fps: c.fps, width: info.width, height: info.height }];
116
+ }));
117
+ return Object.keys(table).length ? `window.LitClips = ${JSON.stringify(table)};\n` : null;
118
+ } catch { return null; }
119
+ }
@@ -195,6 +195,19 @@ export const STAGE = Object.freeze({
195
195
  determinismMax: 16,
196
196
  });
197
197
 
198
+ // Keyed overlay clips (references/stage.md, Clips): frame folders declared in stage/clips/clips.json
199
+ // are held to these caps instead of the raster limits above. A clip is a sticker on the film, never
200
+ // the film: 8-bit RGBA PNG frames with real transparency, at most `side` px on the long side.
201
+ export const CLIP_LIMITS = Object.freeze({
202
+ clips: 12,
203
+ framesPerClip: 360,
204
+ framesTotal: 1500,
205
+ bytesPerClip: 40 * 1024 * 1024,
206
+ bytesTotal: 128 * 1024 * 1024,
207
+ side: 720,
208
+ fps: 30,
209
+ });
210
+
198
211
  // Cover mode (references/cover.md): the README cover loop and the gate that reads its DECODED file.
199
212
  // Calibrated on 2026-10-20 against eight earlier painted covers, four slide-like negatives and
200
213
  // three pilot loops; a number moves only with a new calibration table. FLOOR fails a cover (every
@@ -137,7 +137,7 @@ function prepare({ out, env }) {
137
137
  const treatment = loadCoverTreatment(out);
138
138
  const dir = join(out, COVER_DIR);
139
139
  if (!existsSync(join(dir, 'index.html'))) throw new BlockedError(EXIT.STAGE_CONTRACT, `STAGE_CONTRACT_ERROR: ${join(dir, 'index.html')} is missing; write the cover page first (start from a recipe in recipes/)`);
140
- const scan = scanStage(dir);
140
+ const scan = scanStage(dir, { clips: false });
141
141
  const rename = (line) => line.replaceAll('stage/', `${COVER_DIR}/`);
142
142
  if (scan.network.length) throw new BlockedError(EXIT.STAGE_NETWORK, `STAGE_NETWORK_REQUEST: ${rename(scan.network[0])}; a cover loads only its own files and the /lit/ routes`);
143
143
  if (scan.contract.length) throw new BlockedError(EXIT.STAGE_CONTRACT, `STAGE_CONTRACT_ERROR: ${rename(scan.contract[0])}`);
@@ -124,6 +124,7 @@ export function stageReportText({ manifest, gate, withheld, perf, outDir, treatm
124
124
  lines.push(`path: stage · format ${manifest.format} (${manifest.resolution.join('x')}) · ${manifest.durationSec.toFixed(2)} s of ${treatment.durationSec} s target @ ${manifest.fps} fps`);
125
125
  lines.push(`sound: ${manifest.sound?.label ?? 'n/a'}${manifest.sound?.timbre ? ` (${manifest.sound.timbre}, ${manifest.sound.key}, ${manifest.sound.tempo} BPM, ${manifest.sound.lufs?.toFixed?.(1)} LUFS)` : ''}`);
126
126
  lines.push(`renderer: ${manifest.renderer}`);
127
+ if (manifest.clips?.length) lines.push(`clips: ${manifest.clips.map((c) => `${c.id} ${c.frames} frames at ${c.fps} fps`).join(', ')} (keyed overlays, chosen from film time)`);
127
128
  lines.push('');
128
129
  lines.push(`QA gate: ${gate.status}`);
129
130
  for (const r of gate.results) lines.push(` ${`${r.id}:`.padEnd(30)}${r.status} (${r.detail})`);