@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.
- package/.grok/rules/00-litgrok.md +13 -0
- package/.grok/skills/lit-adhd/SKILL.md +10 -4
- package/.grok/skills/lit-adhd/scripts/lib/brief.mjs +4 -1
- package/.grok/skills/lit-adhd/scripts/lib/collect.mjs +14 -1
- package/.grok/skills/lit-adhd/scripts/lib/next-step.mjs +46 -5
- package/.grok/skills/lit-init/SKILL.md +4 -0
- package/.grok/skills/lit-typographic-motion/references/stage.md +50 -0
- package/.grok/skills/lit-typographic-motion/requirements-audio.txt +92 -89
- package/.grok/skills/lit-typographic-motion/scripts/lib/clips.mjs +119 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/constants.mjs +13 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/cover.mjs +1 -1
- package/.grok/skills/lit-typographic-motion/scripts/lib/stage-gate.mjs +1 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/stage-session.mjs +3 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/stage.mjs +13 -4
- package/.grok/skills/lit-typographic-motion/scripts/stage/stage-kit.js +44 -0
- package/.grok/skills/readme-studio/templates/remotion/package-lock.json +3 -3
- package/.grok/skills/readme-studio/templates/remotion/package.json +3 -0
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/bin/ground-rule.mjs +573 -0
- package/bin/litgrok.mjs +42 -5
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +2 -2
- package/docs/reference_ko-KR.md +2 -2
- package/package.json +1 -1
- 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
|
|
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.
|
|
122
|
-
2.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
396
|
-
--hash=sha256:
|
|
397
|
-
--hash=sha256:
|
|
398
|
-
--hash=sha256:
|
|
399
|
-
--hash=sha256:
|
|
400
|
-
--hash=sha256:
|
|
401
|
-
--hash=sha256:
|
|
402
|
-
--hash=sha256:
|
|
403
|
-
--hash=sha256:
|
|
404
|
-
--hash=sha256:
|
|
405
|
-
--hash=sha256:
|
|
406
|
-
--hash=sha256:
|
|
407
|
-
--hash=sha256:
|
|
408
|
-
--hash=sha256:
|
|
409
|
-
--hash=sha256:
|
|
410
|
-
--hash=sha256:
|
|
411
|
-
--hash=sha256:
|
|
412
|
-
--hash=sha256:
|
|
413
|
-
--hash=sha256:
|
|
414
|
-
--hash=sha256:
|
|
415
|
-
--hash=sha256:
|
|
416
|
-
--hash=sha256:
|
|
417
|
-
--hash=sha256:
|
|
418
|
-
--hash=sha256:
|
|
419
|
-
--hash=sha256:
|
|
420
|
-
--hash=sha256:
|
|
421
|
-
--hash=sha256:
|
|
422
|
-
--hash=sha256:
|
|
423
|
-
--hash=sha256:
|
|
424
|
-
--hash=sha256:
|
|
425
|
-
--hash=sha256:
|
|
426
|
-
--hash=sha256:
|
|
427
|
-
--hash=sha256:
|
|
428
|
-
--hash=sha256:
|
|
429
|
-
--hash=sha256:
|
|
430
|
-
--hash=sha256:
|
|
431
|
-
--hash=sha256:
|
|
432
|
-
--hash=sha256:
|
|
433
|
-
--hash=sha256:
|
|
434
|
-
--hash=sha256:
|
|
435
|
-
--hash=sha256:
|
|
436
|
-
--hash=sha256:
|
|
437
|
-
--hash=sha256:
|
|
438
|
-
--hash=sha256:
|
|
439
|
-
--hash=sha256:
|
|
440
|
-
--hash=sha256:
|
|
441
|
-
--hash=sha256:
|
|
442
|
-
--hash=sha256:
|
|
443
|
-
--hash=sha256:
|
|
444
|
-
--hash=sha256:
|
|
445
|
-
--hash=sha256:
|
|
446
|
-
--hash=sha256:
|
|
447
|
-
--hash=sha256:
|
|
448
|
-
--hash=sha256:
|
|
449
|
-
--hash=sha256:
|
|
450
|
-
--hash=sha256:
|
|
451
|
-
--hash=sha256:
|
|
452
|
-
--hash=sha256:
|
|
453
|
-
--hash=sha256:
|
|
454
|
-
--hash=sha256:
|
|
455
|
-
--hash=sha256:
|
|
456
|
-
--hash=sha256:
|
|
457
|
-
--hash=sha256:
|
|
458
|
-
--hash=sha256:
|
|
459
|
-
--hash=sha256:
|
|
460
|
-
--hash=sha256:
|
|
461
|
-
--hash=sha256:
|
|
462
|
-
--hash=sha256:
|
|
463
|
-
--hash=sha256:
|
|
464
|
-
--hash=sha256:
|
|
465
|
-
--hash=sha256:
|
|
466
|
-
--hash=sha256:
|
|
467
|
-
--hash=sha256:
|
|
468
|
-
--hash=sha256:
|
|
469
|
-
--hash=sha256:
|
|
470
|
-
--hash=sha256:
|
|
471
|
-
--hash=sha256:
|
|
472
|
-
--hash=sha256:
|
|
473
|
-
--hash=sha256:
|
|
474
|
-
--hash=sha256:
|
|
475
|
-
--hash=sha256:
|
|
476
|
-
--hash=sha256:
|
|
477
|
-
--hash=sha256:
|
|
478
|
-
--hash=sha256:
|
|
479
|
-
--hash=sha256:
|
|
480
|
-
--hash=sha256:
|
|
481
|
-
--hash=sha256:
|
|
482
|
-
--hash=sha256:
|
|
483
|
-
--hash=sha256:
|
|
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})`);
|