@litfamily/litopencode 1.0.36 โ 1.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/state.d.ts +1 -0
- package/dist/state.js +1 -0
- package/dist/tools.d.ts +15 -5
- package/dist/tui/mission-band.d.ts +7 -0
- package/dist/tui/mission-band.js +4 -0
- package/dist/tui/mission-skills.js +3 -1
- package/dist/tui/mission-source-v2.d.ts +23 -0
- package/dist/tui/mission-source-v2.js +29 -4
- package/dist/tui/mission-source.d.ts +1 -0
- package/dist/tui/mission-source.js +3 -2
- package/dist/tui/mission-text.d.ts +3 -3
- package/dist/tui/mission-text.js +5 -5
- package/dist/v2/events.d.ts +1 -1
- package/dist/v2/events.js +31 -5
- package/dist/v2/notices.d.ts +13 -0
- package/dist/v2/notices.js +143 -0
- package/dist/v2/tui-footer.d.ts +20 -0
- package/dist/v2/tui-footer.js +56 -0
- package/dist/v2/tui-notices.d.ts +7 -0
- package/dist/v2/tui-notices.js +45 -0
- package/dist/v2/tui.d.ts +43 -1
- package/dist/v2/tui.js +345 -32
- package/dist/v2/usage-limits.d.ts +19 -0
- package/dist/v2/usage-limits.js +70 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +24 -5
- package/docs/reference.md +29 -7
- package/package.json +6 -2
- package/skills/lit-typographic-motion/engine/cover/render.mjs +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +3 -2
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +1 -0
- package/skills/lit-typographic-motion/engine/stage/render.mjs +1 -0
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +109 -5
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +39 -1
- package/skills/lit-typographic-motion/references/cover.md +2 -1
- package/skills/lit-typographic-motion/references/runtime.md +1 -1
- package/skills/lit-typographic-motion/references/stage.md +48 -2
- package/skills/lit-typographic-motion/requirements-audio.txt +92 -89
- package/skills/managed-skill-manifest.json +13 -13
- package/skills/readme-studio/templates/hyperframes/package-lock.json +4 -4
- package/skills/readme-studio/templates/remotion/package-lock.json +3 -3
- package/skills/readme-studio/templates/remotion/package.json +1 -1
package/docs/reference.md
CHANGED
|
@@ -498,12 +498,34 @@ settings but does not send them yet.
|
|
|
498
498
|
TUI: the line under the prompt (`prompt.footer.status`) reads `๐ฅ LIT IGNITED ยท <discipline>` while the
|
|
499
499
|
latest prompt of the session is a lit prompt. On OpenCode 1 the mark stays where it was.
|
|
500
500
|
- *Toasts.* The server cannot show toasts on OpenCode 2. The TUI raises the ignition toast itself when a
|
|
501
|
-
user message arrives with the lit mark the server writes into its metadata
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
OpenCode 1 only
|
|
501
|
+
user message arrives with the lit mark the server writes into its metadata, and it raises the same gate
|
|
502
|
+
toasts as OpenCode 1 (plan saved, goal set, criterion passed, blockers, complete) plus three more that
|
|
503
|
+
only OpenCode 2 has: the workflow stage moving (`๐ฅ Stage`, `work 3/7`), a switch of discipline
|
|
504
|
+
(`๐ฅ lit-loop โ start-work`) and a lane starting or finishing. A burst folds into one toast with a
|
|
505
|
+
`+N more` tail. The restart notice, the `lit adhd` suggestion and the opt-in finish alert work as on
|
|
506
|
+
OpenCode 1. The automatic-handoff notices, which only the server knew about, reach the screen through
|
|
507
|
+
`.litopencode/notices.jsonl`: the OpenCode 2 server plugin can only subscribe to events and has no way to
|
|
508
|
+
raise a toast, so it appends each notice there and the TUI plugin shows the lines that appear after it
|
|
509
|
+
started (the file is capped and never follows a symbolic link). The one popup the server raised besides
|
|
510
|
+
those, the ignition toast, is already the TUI's own, so there is no separate skill hint left to port.
|
|
511
|
+
- *TUI.* The HUD is placed in the slots OpenCode 2 publishes: the status card is claimed in `app`, under
|
|
512
|
+
the session and the sidebar, and keeps its four rows idle or working; the stage chip shares
|
|
513
|
+
`prompt.footer.status` with the ignition line, because there is no slot right of the agent and model
|
|
514
|
+
row; the home line is in `home.footer`; the mission panel sits in `sidebar.content`, so it shows when
|
|
515
|
+
the sidebar is open. The card, chip, panel and home line use the same text builders as OpenCode 1. The
|
|
516
|
+
`band` and `notify` options of the plugin entry are read from the options OpenCode 2 hands over. The footer
|
|
517
|
+
line steps down with the terminal width instead of being clipped by the host: mark plus the whole chip,
|
|
518
|
+
mark plus a smaller chip, the chip alone, the chip as `๐ฅ 1/3`, the bare flame, then nothing; the room is the
|
|
519
|
+
width less the sidebar (assumed open above 120 columns) and the host's own spinner and figures. OpenCode 2
|
|
520
|
+
keeps no todo list, so mission progress comes from the ledger, the work record and the plan. It names
|
|
521
|
+
its tools differently (`shell`, `subagent`, a `skill` tool that takes `id`), and the card reads those
|
|
522
|
+
names. OpenCode 2 gives plugins one usage source, `client.usage.limits()`: the OpenCode Go allowance of the
|
|
523
|
+
Console workspace the location is signed in to (a rolling five-hour window, a weekly one and a monthly
|
|
524
|
+
one). For a session whose latest answer came from the `opencode-go` provider the card's bottom border shows
|
|
525
|
+
`5h 42% ยท 1w 18%` after the context figure (the monthly window only from 90%, and a window the host reports
|
|
526
|
+
as refusing requests reads `limited`). It asks at most once a minute. For any other provider, a signed-out
|
|
527
|
+
host (the reply is `null`) or an error, nothing is drawn; OpenCode 2 exposes no provider rate-limit headers or
|
|
528
|
+
events to a plugin, so there is no 5-hour or weekly figure for the Anthropic or OpenAI plans.
|
|
507
529
|
- *Updates.* There is no automatic self-update inside the plugin on OpenCode 2. Run
|
|
508
530
|
`opencode plugin update`, or let OpenCode 2's own update policy handle it.
|
|
509
531
|
|
|
@@ -586,7 +608,7 @@ What it draws:
|
|
|
586
608
|
the colour wheel at one fixed lightness, four small colour steps a second with no flashing. It
|
|
587
609
|
is off under `motion: false` and `NO_COLOR`. An idle session in a workspace with earlier work
|
|
588
610
|
says `lit adhd to pick up where you left off`, and the first fresh session there raises one
|
|
589
|
-
toast with the same words (
|
|
611
|
+
toast with the same words (OpenCode 1 and 2).
|
|
590
612
|
- one look at a time, picked from a small registry in `src/tui/mission-looks.ts`. Every route that runs
|
|
591
613
|
lit-adhd wears the rewind look: `lit adhd`/`lit rewind` in chat, `/lit-adhd`/`/lit-rewind` (also from an
|
|
592
614
|
installed v2 alias file) and a native `skill` load. While the session waits on you (a permission or a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@litfamily/litopencode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.38",
|
|
4
4
|
"description": "Workflow plugin for OpenCode: add lit to a prompt and the work is planned, built, checked and written down so the next session can pick it up.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"yaml": "^2.9.0",
|
|
73
|
-
"zod": "4.
|
|
73
|
+
"zod": "4.6.6"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@opencode-ai/plugin": "^1.17.6",
|
|
@@ -79,6 +79,10 @@
|
|
|
79
79
|
"picomatch": "^4.0.5",
|
|
80
80
|
"typescript": "^5.9.3"
|
|
81
81
|
},
|
|
82
|
+
"overrides": {
|
|
83
|
+
"zod": "$zod",
|
|
84
|
+
"toml": "5.0.1"
|
|
85
|
+
},
|
|
82
86
|
"publishConfig": {
|
|
83
87
|
"access": "public"
|
|
84
88
|
}
|
|
@@ -108,7 +108,7 @@ function prepare(out) {
|
|
|
108
108
|
const cover = loadCoverTreatment(out);
|
|
109
109
|
const coverDir = path.join(out, "cover");
|
|
110
110
|
if (!existsSync(path.join(coverDir, "index.html"))) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `no cover page at ${path.join(coverDir, "index.html")}; write it first (references/cover.md)`);
|
|
111
|
-
const scan = scanStage(coverDir);
|
|
111
|
+
const scan = scanStage(coverDir, { clips: false });
|
|
112
112
|
const assets = checkAssets({ out, treatment: cover, rasters: scan.rasters });
|
|
113
113
|
const state = inspectWarmState();
|
|
114
114
|
if (state.blocked) throw stop(state.blocked.exit, state.blocked.name, `${state.blocked.detail}; fix: ${state.fix}`);
|
|
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { chromeFlagRungs } from "../constants.mjs";
|
|
9
|
-
import { StageError, contentTypes, insideDir } from "./scan.mjs";
|
|
9
|
+
import { StageError, clipsScript, contentTypes, insideDir } from "./scan.mjs";
|
|
10
10
|
|
|
11
11
|
export const stageHost = "lit.stage";
|
|
12
12
|
export const stageOrigin = `http://${stageHost}`;
|
|
@@ -108,7 +108,8 @@ export async function openStage(chromium, { chromePath, profile, stageDir, width
|
|
|
108
108
|
const line = String(error?.message ?? error).split("\n").find((part) => part.trim()) ?? String(error);
|
|
109
109
|
throw Object.assign(new Error(`BLOCKED_NO_CHROME (10): Chrome failed to launch headless: ${line}; fix: check that Chrome can start headless in this sandbox`), { exitCode: 10 });
|
|
110
110
|
}
|
|
111
|
-
const
|
|
111
|
+
const clips = clipsScript(stageDir);
|
|
112
|
+
const { blocked, refused, missing } = await serveStage(context, { stageDir, fonts, scripts: { "/lit/stage-kit.js": kitSource(), ...(clips ? { "/lit/clips.js": clips } : {}), ...styleFiles } });
|
|
112
113
|
await context.addInitScript({ content: `window.__litStageConfig = ${JSON.stringify({ seed: seed >>> 0, fps })};\n${clockSource()}` });
|
|
113
114
|
if (extraInit) await context.addInitScript({ content: extraInit });
|
|
114
115
|
const page = context.pages()[0] ?? await context.newPage();
|
|
@@ -146,6 +146,7 @@ export function formatStageReport(result, context) {
|
|
|
146
146
|
`outputs: ${out("film.mp4")} ยท ${out(manifest.previewEncoder === "gif" ? "preview.gif" : "preview.webp")} (encoder: ${manifest.previewEncoder}) ยท ${out("poster.png")} ยท ${out("reduced-motion.png")}`,
|
|
147
147
|
`format / fps / length: ${manifest.format} ${manifest.resolution.join("x")} @ ${manifest.fps} fps, ${manifest.durationSec.toFixed(2)} s (treatment ${manifest.targetDurationSec} s)`,
|
|
148
148
|
`renderer: stage ${manifest.rung === "gpu" ? `GPU rung (${manifest.renderer ?? "native ANGLE"})` : "software rung"} (flags: ${(manifest.chromeFlags ?? []).join(" ")})`,
|
|
149
|
+
...(manifest.clips?.length ? [`clips: ${manifest.clips.map((clip) => `${clip.id} ${clip.frames} frames at ${clip.fps} fps`).join(", ")} (keyed overlays, chosen from film time)`] : []),
|
|
149
150
|
...(manifest.warnings ?? []).map((note) => `notice: ${note}`),
|
|
150
151
|
`timing: master ${manifest.timing?.masterSec ?? "?"} s, per frame p50 ${manifest.timing?.frameP50 ?? "?"} ms / p95 ${manifest.timing?.frameP95 ?? "?"} ms, total ${manifest.timing?.totalSec ?? "?"} s`,
|
|
151
152
|
"",
|
|
@@ -450,6 +450,7 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
|
|
|
450
450
|
preview: preview ? { width: preview.width, height: preview.height, fps: preview.fps, bytes: preview.bytes } : null,
|
|
451
451
|
posterFrame, posterSize: [poster.width, poster.height], stillFrame, determinismFrames: samples,
|
|
452
452
|
timing: { ...master.timing, totalSec: Number(((performance.now() - began) / 1000).toFixed(1)) },
|
|
453
|
+
...(context.scan.clips.length ? { clips: context.scan.clips.map((clip) => ({ id: clip.id, frames: clip.count, fps: clip.fps, width: clip.width, height: clip.height, bytes: clip.bytes })) } : {}),
|
|
453
454
|
...(context.plate ? { plate: { blender: context.plate.blender, engine: context.plate.engine, device: context.plate.device, samples: context.plate.samples, fps: context.plate.fps, frames: context.plate.frames.length } } : {}),
|
|
454
455
|
sound, qa: { file: ".run/qa.json", samples: qaResult.samples.length, trackFrames: qaResult.trackFrames, allowedFaces: qaResult.allowedFaces },
|
|
455
456
|
treatmentSha256: sha256(readFileSync(path.join(out, "treatment.json"))), stageSha256: stageSha(context.stageDir),
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import { decodePng } from "../png.mjs";
|
|
8
9
|
|
|
9
10
|
export const STAGE_CONTRACT = 17;
|
|
10
11
|
export const STAGE_NETWORK = 19;
|
|
@@ -32,6 +33,11 @@ export function readPlate(stageDir, sub = "plate") {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export const rasterLimits = Object.freeze({ files: 24, bytes: 8 * 1024 * 1024, sameSizeFlipbook: 10 });
|
|
36
|
+
// Keyed overlay clips: frame folders that stage/clips/clips.json declares are held to these caps instead of
|
|
37
|
+
// rasterLimits. A clip is a sticker on the film, never the film: 8-bit RGBA PNG frames with real
|
|
38
|
+
// transparency, at most `side` px on the long side, at most `fps` frames a second.
|
|
39
|
+
export const clipLimits = Object.freeze({ clips: 12, framesPerClip: 360, framesTotal: 1500, bytesPerClip: 40 * 1024 * 1024, bytesTotal: 128 * 1024 * 1024, side: 720, fps: 30 });
|
|
40
|
+
export const clipsDir = "clips";
|
|
35
41
|
// XML namespace identifiers are names, not requests.
|
|
36
42
|
const namespaceUris = /https?:\/\/www\.w3\.org\/(?:2000\/svg|1999\/xlink|1999\/xhtml|XML\/1998\/namespace|2000\/xmlns\/?|1998\/Math\/MathML)/gu;
|
|
37
43
|
|
|
@@ -76,7 +82,7 @@ export function rasterInfo(bytes, ext) {
|
|
|
76
82
|
if (type === "IDAT" || type === "IEND") break;
|
|
77
83
|
offset += 12 + length;
|
|
78
84
|
}
|
|
79
|
-
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), animated };
|
|
85
|
+
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20), animated, bitDepth: bytes[24], colorType: bytes[25] };
|
|
80
86
|
}
|
|
81
87
|
if (ext === ".gif") {
|
|
82
88
|
const width = bytes.readUInt16LE(6), height = bytes.readUInt16LE(8);
|
|
@@ -123,10 +129,108 @@ export function rasterInfo(bytes, ext) {
|
|
|
123
129
|
return null;
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
//
|
|
127
|
-
|
|
132
|
+
// ---- declared clips: stage/clips/clips.json and the frame folders it names ----
|
|
133
|
+
const clipFrame = (n) => `frame_${String(n).padStart(4, "0")}.png`;
|
|
134
|
+
const clipId = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/u;
|
|
135
|
+
const clipFolder = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u;
|
|
136
|
+
|
|
137
|
+
// The clips stage/clips/clips.json declares ([] without the file), checked for shape only. Throws exit 17.
|
|
138
|
+
export function declaredClips(stageDir, limits = clipLimits) {
|
|
139
|
+
const file = path.join(stageDir, clipsDir, "clips.json");
|
|
140
|
+
if (!existsSync(file)) return [];
|
|
141
|
+
let doc;
|
|
142
|
+
try { doc = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw contract(`${clipsDir}/clips.json is not valid JSON (${error.message})`); }
|
|
143
|
+
if (!doc || !Array.isArray(doc.clips)) throw contract(`${clipsDir}/clips.json needs { "clips": [ { "id", "dir", "count", "fps" } ] }`);
|
|
144
|
+
if (doc.clips.length > limits.clips) throw contract(`${doc.clips.length} clips declared (at most ${limits.clips} clips)`);
|
|
145
|
+
const ids = new Set();
|
|
146
|
+
const dirs = new Set();
|
|
147
|
+
return doc.clips.map((entry, n) => {
|
|
148
|
+
const at = `${clipsDir}/clips.json clip #${n + 1}`;
|
|
149
|
+
if (!entry || typeof entry !== "object") throw contract(`${at} is not an object`);
|
|
150
|
+
if (typeof entry.id !== "string" || !clipId.test(entry.id)) throw contract(`${at}: id must be a plain token of letters, digits, - and _ (got ${JSON.stringify(entry.id)})`);
|
|
151
|
+
if (ids.has(entry.id)) throw contract(`${at}: duplicate id ${entry.id}`);
|
|
152
|
+
ids.add(entry.id);
|
|
153
|
+
if (typeof entry.dir !== "string" || !clipFolder.test(entry.dir) || entry.dir.includes("..")) throw contract(`clip "${entry.id}": dir must be a folder name directly under ${clipsDir}/ (got ${JSON.stringify(entry.dir)})`);
|
|
154
|
+
if (dirs.has(entry.dir)) throw contract(`clip "${entry.id}": dir ${entry.dir} is already declared by another clip`);
|
|
155
|
+
dirs.add(entry.dir);
|
|
156
|
+
if (!Number.isInteger(entry.count) || entry.count < 1) throw contract(`clip "${entry.id}": count must be a whole number of frames (got ${JSON.stringify(entry.count)})`);
|
|
157
|
+
if (!Number.isInteger(entry.fps) || entry.fps < 1 || entry.fps > limits.fps) throw contract(`clip "${entry.id}": fps must be a whole number from 1 to ${limits.fps} (got ${JSON.stringify(entry.fps)})`);
|
|
158
|
+
return { id: entry.id, dir: entry.dir, count: entry.count, fps: entry.fps };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Checks every declared clip against its bytes: exactly frame_0001.png..frame_<count>.png in clips/<dir>/,
|
|
163
|
+
// each an 8-bit still RGBA PNG of one size within the overlay cap, real transparency in the first, middle
|
|
164
|
+
// and last frame, and the per-clip and total caps. Returns [{ id, dir, count, fps, width, height, bytes,
|
|
165
|
+
// frames }], `frames` being stage-relative paths.
|
|
166
|
+
export function scanClips(stageDir, { limits = clipLimits, files = listStage(stageDir) } = {}) {
|
|
167
|
+
let totalFrames = 0;
|
|
168
|
+
let totalBytes = 0;
|
|
169
|
+
const clips = declaredClips(stageDir, limits).map((declared) => {
|
|
170
|
+
const prefix = `${clipsDir}/${declared.dir}/`;
|
|
171
|
+
if (declared.count > limits.framesPerClip) throw contract(`clip "${declared.id}": ${declared.count} frames (at most ${limits.framesPerClip} per clip)`);
|
|
172
|
+
const have = new Set(files.filter((rel) => rel.startsWith(prefix) && rasterExtensions.has(path.extname(rel).toLowerCase())).map((rel) => rel.slice(prefix.length)));
|
|
173
|
+
const frames = [];
|
|
174
|
+
for (let n = 1; n <= declared.count; n++) {
|
|
175
|
+
if (!have.has(clipFrame(n))) throw contract(`clip "${declared.id}": ${prefix}${clipFrame(n)} is missing (declared ${declared.count} frames, named frame_0001.png onward)`);
|
|
176
|
+
frames.push(`${prefix}${clipFrame(n)}`);
|
|
177
|
+
}
|
|
178
|
+
if (have.size !== declared.count) {
|
|
179
|
+
const stray = [...have].find((name) => !frames.includes(`${prefix}${name}`));
|
|
180
|
+
throw contract(`clip "${declared.id}": ${prefix}${stray} is not one of the ${declared.count} declared frames; raise count or remove it`);
|
|
181
|
+
}
|
|
182
|
+
let size = null;
|
|
183
|
+
let bytes = 0;
|
|
184
|
+
for (const rel of frames) {
|
|
185
|
+
const data = readFileSync(path.join(stageDir, rel));
|
|
186
|
+
bytes += data.length;
|
|
187
|
+
const info = path.extname(rel).toLowerCase() === ".png" && data.length > 33 ? rasterInfo(data, ".png") : null;
|
|
188
|
+
if (!info) throw contract(`clip "${declared.id}": ${rel} is not a readable PNG`);
|
|
189
|
+
if (info.animated) throw contract(`clip "${declared.id}": ${rel} is an animated image; a clip is a folder of still frames`);
|
|
190
|
+
if (info.colorType !== 6 || info.bitDepth !== 8) throw contract(`clip "${declared.id}": ${rel} must be an 8-bit RGBA PNG with an alpha channel (a keyed frame), not an opaque image`);
|
|
191
|
+
if (Math.max(info.width, info.height) > limits.side) throw contract(`clip "${declared.id}": ${rel} is ${info.width}x${info.height}; a clip is an overlay of at most ${limits.side} px on the long side`);
|
|
192
|
+
if (size && (size.width !== info.width || size.height !== info.height)) throw contract(`clip "${declared.id}": ${rel} is ${info.width}x${info.height} but frame_0001.png is ${size.width}x${size.height}; one size per clip`);
|
|
193
|
+
size = { width: info.width, height: info.height };
|
|
194
|
+
}
|
|
195
|
+
if (bytes > limits.bytesPerClip) throw contract(`clip "${declared.id}": ${(bytes / 1048576).toFixed(1)} MB (at most ${(limits.bytesPerClip / 1048576).toFixed(0)} MB per clip)`);
|
|
196
|
+
for (const rel of [frames[0], frames[Math.floor((frames.length - 1) / 2)], frames.at(-1)]) {
|
|
197
|
+
let pixels;
|
|
198
|
+
try { ({ pixels } = decodePng(readFileSync(path.join(stageDir, rel)))); } catch (error) { throw contract(`clip "${declared.id}": ${rel} cannot be decoded (${error.message}); use a non-interlaced 8-bit RGBA PNG`); }
|
|
199
|
+
let transparent = false;
|
|
200
|
+
for (let i = 3; i < pixels.length && !transparent; i += 4) transparent = pixels[i] < 255;
|
|
201
|
+
if (!transparent) throw contract(`clip "${declared.id}": ${rel} has no transparent pixel; a clip is keyed, not a full-frame video`);
|
|
202
|
+
}
|
|
203
|
+
totalFrames += declared.count;
|
|
204
|
+
totalBytes += bytes;
|
|
205
|
+
return { ...declared, ...size, bytes, frames };
|
|
206
|
+
});
|
|
207
|
+
if (totalFrames > limits.framesTotal) throw contract(`${totalFrames} clip frames in all (at most ${limits.framesTotal})`);
|
|
208
|
+
if (totalBytes > limits.bytesTotal) throw contract(`${(totalBytes / 1048576).toFixed(1)} MB of clip frames in all (at most ${(limits.bytesTotal / 1048576).toFixed(0)} MB)`);
|
|
209
|
+
return clips;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// window.LitClips for LitStage.clip: id -> { src, count, fps, width, height }, written from clips.json and
|
|
213
|
+
// the first frame's header. null when the stage declares no clips (or one the scan would refuse).
|
|
214
|
+
export function clipsScript(stageDir) {
|
|
215
|
+
let table;
|
|
216
|
+
try {
|
|
217
|
+
table = Object.fromEntries(declaredClips(stageDir).map((clip) => {
|
|
218
|
+
const first = path.join(stageDir, clipsDir, clip.dir, clipFrame(1));
|
|
219
|
+
const info = rasterInfo(readFileSync(first), ".png");
|
|
220
|
+
return [clip.id, { src: `${clipsDir}/${clip.dir}`, count: clip.count, fps: clip.fps, width: info.width, height: info.height }];
|
|
221
|
+
}));
|
|
222
|
+
} catch { return null; }
|
|
223
|
+
return Object.keys(table).length ? `window.LitClips = ${JSON.stringify(table)};\n` : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Throws a StageError for the first violation; returns the raster list for pre-decoding and the declared
|
|
227
|
+
// clips, whose frames are not page rasters. `clips: false` (a cover page) ignores clips.json, so a clip
|
|
228
|
+
// folder there is held to the raster limits.
|
|
229
|
+
export function scanStage(stageDir, { clips: allowClips = true } = {}) {
|
|
128
230
|
const files = listStage(stageDir);
|
|
129
231
|
if (!files.includes("index.html")) throw contract("stage/index.html is missing");
|
|
232
|
+
const clips = allowClips ? scanClips(stageDir, { files }) : [];
|
|
233
|
+
const clipped = new Set(clips.flatMap((clip) => clip.frames));
|
|
130
234
|
const rasters = [];
|
|
131
235
|
let rasterBytes = 0;
|
|
132
236
|
const plates = ["plate", "plate-preview"].map((sub) => readPlate(stageDir, sub)).filter(Boolean);
|
|
@@ -134,7 +238,7 @@ export function scanStage(stageDir) {
|
|
|
134
238
|
for (const rel of files) {
|
|
135
239
|
const ext = path.extname(rel).toLowerCase();
|
|
136
240
|
const full = path.join(stageDir, rel);
|
|
137
|
-
if (rel === "scene.py" || plated.has(rel)) continue;
|
|
241
|
+
if (rel === "scene.py" || plated.has(rel) || clipped.has(rel)) continue;
|
|
138
242
|
if (!Object.hasOwn(contentTypes, ext)) throw contract(`${rel}: ${ext || "extensionless"} files are not served on the stage`);
|
|
139
243
|
if (rasterExtensions.has(ext)) {
|
|
140
244
|
const bytes = readFileSync(full);
|
|
@@ -170,7 +274,7 @@ export function scanStage(stageDir) {
|
|
|
170
274
|
const bySize = new Map();
|
|
171
275
|
for (const raster of rasters) bySize.set(`${raster.width}x${raster.height}`, [...(bySize.get(`${raster.width}x${raster.height}`) ?? []), raster.rel]);
|
|
172
276
|
for (const [size, list] of bySize) if (list.length >= rasterLimits.sameSizeFlipbook) throw contract(`${list.length} rasters share the size ${size} (a flipbook); rasters are textures and stills, never a frame sequence`);
|
|
173
|
-
return { files, rasters };
|
|
277
|
+
return { files, rasters, clips };
|
|
174
278
|
}
|
|
175
279
|
|
|
176
280
|
// One hash over every stage file's name and bytes: a render records it, the done check compares it.
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
// ---- masks and clips ----
|
|
230
|
-
const
|
|
230
|
+
const clipMasks = {
|
|
231
231
|
// A circle growing from (cx, cy) in percent of the element's box.
|
|
232
232
|
circle(el, p, { cx = 50, cy = 50, max = 150 } = {}) { el.style.clipPath = `circle(${clamp(p) * max}% at ${cx}% ${cy}%)`; },
|
|
233
233
|
// A straight wipe from one side: "left", "right", "top" or "bottom".
|
|
@@ -370,6 +370,44 @@
|
|
|
370
370
|
};
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
+
// A declared keyed clip (stage/clips/clips.json, served as window.LitClips by /lit/clips.js): one wrapper
|
|
374
|
+
// at `box` over the film, holding the clip's frame for t. The frame is a pure function of film time,
|
|
375
|
+
// floor((t - start) * fps) + 1 within from..to, so no <video> and no real clock is involved. Before
|
|
376
|
+
// `start` and after `to` the wrapper is hidden (null) unless `hold` keeps the last frame or `loop` wraps
|
|
377
|
+
// by frame index. The <img> inside is replaced on a frame change; the renderer waits for its decode.
|
|
378
|
+
// The same name carries the clip-path helpers (clip.circle, clip.wipe, clip.iris), so both keep working.
|
|
379
|
+
function keyedClip({ parent, id, box } = {}) {
|
|
380
|
+
const all = window.LitClips;
|
|
381
|
+
if (!all) throw new Error("LitStage.clip: load <script src=\"/lit/clips.js\"></script> after the kit; the stage declares no clips (clips/clips.json)");
|
|
382
|
+
const c = all[id];
|
|
383
|
+
if (!c) throw new Error(`LitStage.clip: no clip "${id}" in clips/clips.json (declared: ${Object.keys(all).join(", ")})`);
|
|
384
|
+
if (!parent || !box || !(box.w > 0) || !(box.h > 0)) throw new Error("LitStage.clip: needs { parent, id, box: { x, y, w, h } }");
|
|
385
|
+
const wrap = document.createElement("div");
|
|
386
|
+
wrap.setAttribute("data-lit-clip", id);
|
|
387
|
+
Object.assign(wrap.style, { position: "absolute", left: `${box.x}px`, top: `${box.y}px`, width: `${box.w}px`, height: `${box.h}px`, display: "none", pointerEvents: "none" });
|
|
388
|
+
parent.append(wrap);
|
|
389
|
+
let shown = 0, img = null;
|
|
390
|
+
return (t, { start = 0, from = 1, to = c.count, loop = false, hold = false } = {}) => {
|
|
391
|
+
if (!(from >= 1 && to <= c.count && from <= to)) throw new Error(`LitStage.clip: from..to must lie within 1..${c.count} (got ${from}..${to})`);
|
|
392
|
+
const n = to - from + 1;
|
|
393
|
+
const k = Math.floor((t - start) * c.fps + 1e-6);
|
|
394
|
+
const i = k < 0 ? 0 : k < n ? from + k : loop ? from + (k % n) : hold ? to : 0;
|
|
395
|
+
wrap.style.display = i ? "block" : "none";
|
|
396
|
+
if (!i) return null;
|
|
397
|
+
if (i !== shown) {
|
|
398
|
+
const next = document.createElement("img");
|
|
399
|
+
next.src = `${c.src}/frame_${String(i).padStart(4, "0")}.png`;
|
|
400
|
+
next.alt = "";
|
|
401
|
+
Object.assign(next.style, { display: "block", width: "100%", height: "100%", objectFit: "contain" });
|
|
402
|
+
if (img) img.replaceWith(next); else wrap.append(next);
|
|
403
|
+
img = next;
|
|
404
|
+
shown = i;
|
|
405
|
+
}
|
|
406
|
+
return wrap;
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const clip = Object.assign(keyedClip, clipMasks);
|
|
410
|
+
|
|
373
411
|
// ---- the stage contract ----
|
|
374
412
|
let definition = null;
|
|
375
413
|
function define(spec) {
|
|
@@ -33,7 +33,8 @@ mode (the stage and type paths) is unchanged and takes every other request.
|
|
|
33
33
|
The renderer serves only that folder, the loop kit at `/lit/loop-kit.js` and the faces at
|
|
34
34
|
`/lit/fonts.css`. The stage path's static scan applies unchanged: no network, no `<video>`,
|
|
35
35
|
`<audio>` or `<iframe>`, no workers, no WebSocket, at most 24 rasters and 8 MB, no animated
|
|
36
|
-
raster, no flipbook of same-size frames
|
|
36
|
+
raster, no flipbook of same-size frames; a cover takes no keyed clips either, so a folder of
|
|
37
|
+
frames there is a flipbook.
|
|
37
38
|
- `<out>/assets.json`: every raster the page can load (below).
|
|
38
39
|
|
|
39
40
|
Start from a recipe: copy `recipes/<name>/index.html` from the installed skill into `cover/`, then
|
|
@@ -39,7 +39,7 @@ warning, and the pre-warm state naming what is missing and the command that fixe
|
|
|
39
39
|
| 14 | BLOCKED_DEPS_NOT_PREWARMED | the pinned packages are absent (or Tier-3 models are requested) | `litopencode motion-runtime install` outside the session |
|
|
40
40
|
| 15 | BLOCKED_FONT_FETCH | a required font is missing or its bytes do not match the pin | `litopencode motion-runtime install`; a mismatch is never accepted |
|
|
41
41
|
| 16 | BLOCKED_TREATMENT_INVALID | `treatment.json` is missing or a field breaks its rule; the message names the field | fix that field (`treatment.md`) |
|
|
42
|
-
| 17 | STAGE_CONTRACT_ERROR | the stage page broke its contract: no `LitStage.define`, a wrong size, a forbidden element or API, a flipbook or animated raster, a path outside `stage/`, or a copy line never found on screen | fix the page (`stage.md`) |
|
|
42
|
+
| 17 | STAGE_CONTRACT_ERROR | the stage page broke its contract: no `LitStage.define`, a wrong size, a forbidden element or API, a flipbook or animated raster, a keyed clip that breaks its declaration or limits, a path outside `stage/`, or a copy line never found on screen | fix the page (`stage.md`) |
|
|
43
43
|
| 18 | STAGE_NONDETERMINISTIC | a sample frame differs between the master and a fresh replay from 0; the message names the frame and the first region | remove the clock or random source the renderer does not drive |
|
|
44
44
|
| 19 | STAGE_NETWORK_REQUEST | the page requested something outside the stage origin, or a stage file names an external URL | make the asset local |
|
|
45
45
|
| 20 | SOUND_INVALID | the planned track is missing from the film, too long or short, too loud, or a generated bed starts silent; a score that cannot be built | fix the track or its plan |
|
|
@@ -16,7 +16,7 @@ loop time the renderer seeks in any order, with the loop kit instead of this one
|
|
|
16
16
|
into the stage directory.
|
|
17
17
|
- Rasters are textures and stills, never a frame sequence: at most 24 raster files and 8 MB in total.
|
|
18
18
|
Ten or more rasters of one size (a flipbook), or any animated GIF, APNG or WebP, stops the render
|
|
19
|
-
with exit 17.
|
|
19
|
+
with exit 17. The one frame sequence a page may carry is a declared keyed clip (Clips).
|
|
20
20
|
- `<out>/stage/assets/`: the real logo, screenshots and README images of a named product, written by
|
|
21
21
|
`render.mjs assets` (`treatment.md`, Named products). Show them; never redraw them.
|
|
22
22
|
- A film about a thing with a back and an inside takes the 3D route: the renderer serves a pinned
|
|
@@ -76,7 +76,7 @@ unless the film needs another structure.
|
|
|
76
76
|
|
|
77
77
|
| Stops with | What |
|
|
78
78
|
| --- | --- |
|
|
79
|
-
| 17 `STAGE_CONTRACT_ERROR` | `<video>`, `<audio>`, `<iframe>`, `<object>`, `<embed>`, `<frame>`; `new Audio()`, `AudioContext`, `OfflineAudioContext`; `Worker`, `SharedWorker`, service workers; no `LitStage.define`; a wrong size; a flipbook or animated raster; a copy line never found on screen |
|
|
79
|
+
| 17 `STAGE_CONTRACT_ERROR` | `<video>`, `<audio>`, `<iframe>`, `<object>`, `<embed>`, `<frame>`; `new Audio()`, `AudioContext`, `OfflineAudioContext`; `Worker`, `SharedWorker`, service workers; no `LitStage.define`; a wrong size; a flipbook or animated raster; a clip that breaks its declaration or limits; a copy line never found on screen |
|
|
80
80
|
| 18 `STAGE_NONDETERMINISTIC` | a frame that differs between two fresh replays: wall-clock reads such as `performance.timeOrigin`, `crypto.getRandomValues`, or state that depends on how fast frames arrive |
|
|
81
81
|
| 19 `STAGE_NETWORK_REQUEST` | any request outside the stage origin; an absolute `http(s)://` or `//host` URL in a stage file (W3C namespace URIs such as `http://www.w3.org/2000/svg` are fine); `preconnect`, `dns-prefetch`, `prefetch` or `prerender` links; `WebSocket`, `WebTransport`, `RTCPeerConnection` |
|
|
82
82
|
|
|
@@ -147,6 +147,52 @@ LitStage.text(signEl, { decor: true }); // illustrative text i
|
|
|
147
147
|
LitStage.text({ content: "โฆ", x, y, w, h }); // canvas or WebGL text, every frame it is drawn
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
Keyed footage (a declared clip, see Clips): `LitStage.clip` is a function as well as the mask
|
|
151
|
+
namespace above, so `LitStage.clip.wipe(el, p)` and the keyed call live side by side.
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
const boom = LitStage.clip({ parent: stageEl, id: "boom", box: { x: 1200, y: 300, w: 520, h: 511 } });
|
|
155
|
+
boom(t, { start: 2.1 }); // inside render(t); returns the wrapper, or null while hidden
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Clips
|
|
159
|
+
|
|
160
|
+
Real keyed footage (a reaction clip, a spinning-cat loop shot on a green screen) joins the film as a
|
|
161
|
+
layer inside the page. The video is keyed outside the renderer into one 8-bit RGBA PNG per frame, the
|
|
162
|
+
frames are declared, and the page picks the frame from film time. A declared clip is exempt from the
|
|
163
|
+
flipbook and count rule; every other raster keeps its limits. Nothing is encoded by hand: the frames
|
|
164
|
+
are captured with the page, so the flash audit, the determinism replay and the text checks run on the
|
|
165
|
+
final pixels as for any other film.
|
|
166
|
+
|
|
167
|
+
1. Copy each clip's frames into `stage/clips/<dir>/frame_0001.png`, `frame_0002.png` and so on, with
|
|
168
|
+
no gaps and no other image in the folder.
|
|
169
|
+
2. Declare them in `stage/clips/clips.json`:
|
|
170
|
+
|
|
171
|
+
```json
|
|
172
|
+
{ "clips": [ { "id": "boom", "dir": "01-vine-boom", "count": 122, "fps": 30 } ] }
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`id` is the name the page uses; `dir` is a folder directly under `clips/`; `count` is the number of
|
|
176
|
+
frames; `fps` is the clip's own rate, 1 to 30. A clip used twice is declared once.
|
|
177
|
+
3. The page loads `<script src="/lit/clips.js"></script>` after the kit, registers each use once with
|
|
178
|
+
`LitStage.clip({ parent, id, box })` and calls the returned `show(t, opts)` from `render(t)`.
|
|
179
|
+
`parent` is the element the clip sits in; `box` is its place in pixels, the frame fitted inside it.
|
|
180
|
+
`show` returns the wrapper element, or `null` while hidden; scale, fade or move that wrapper from
|
|
181
|
+
`t` like any other layer. The frame is `floor((t - start) * fps) + 1`, counted from `from`, so the
|
|
182
|
+
cue is plain arithmetic: a clip whose own sound starts at frame 85 and must land on a cue at 0.12 s
|
|
183
|
+
has `start = 0.12 - 84 / 30`. Options: `start` (film seconds, default 0), `from` and `to` (the
|
|
184
|
+
frame range, default the whole clip), `loop` (wrap by frame index), `hold` (keep the last frame).
|
|
185
|
+
Before `start` and past `to`, without `loop` or `hold`, the clip is hidden.
|
|
186
|
+
|
|
187
|
+
The scan refuses, with exit 17 and the clip named: more than 12 clips, more than 360 frames in a clip
|
|
188
|
+
or 1500 in all, 40 MB in a clip or 128 MB in all, a frame whose long side is above 720 px (a clip is
|
|
189
|
+
an overlay, not the film), a rate above 30 fps, frames of mixed sizes, a missing or extra frame, a
|
|
190
|
+
frame that is not an 8-bit RGBA PNG or that has no transparent pixel in its first, middle and last
|
|
191
|
+
frame (an opaque video is not keyed), an animated image, and a duplicate `id`. A folder that is not
|
|
192
|
+
declared, or any frame sequence beside a declared clip, is a flipbook as before. A cover takes no
|
|
193
|
+
clips. The text checks read DOM text, so a clip laid over a line is yours to catch on the stills. The
|
|
194
|
+
manifest and the report list each clip.
|
|
195
|
+
|
|
150
196
|
## How the QA reads the stage
|
|
151
197
|
|
|
152
198
|
- Every `copy.lines` entry must appear on screen at some beat; a missing line stops the render with
|