@litfamily/litopencode 1.0.36 → 1.0.37
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 +6 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- 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/v2/tui-notices.d.ts +7 -0
- package/dist/v2/tui-notices.js +45 -0
- package/dist/v2/tui.d.ts +37 -1
- package/dist/v2/tui.js +309 -31
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +13 -5
- package/docs/reference.md +17 -7
- package/package.json +1 -1
- 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/managed-skill-manifest.json +9 -9
|
@@ -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
|
|
@@ -3389,7 +3389,7 @@
|
|
|
3389
3389
|
},
|
|
3390
3390
|
{
|
|
3391
3391
|
"path": "engine/cover/render.mjs",
|
|
3392
|
-
"sha256": "
|
|
3392
|
+
"sha256": "8f819692910d35959aa86b61a271ca6fe4c74547d549a1c8d90da07bdffd62cf"
|
|
3393
3393
|
},
|
|
3394
3394
|
{
|
|
3395
3395
|
"path": "engine/cover/sheet.mjs",
|
|
@@ -3477,7 +3477,7 @@
|
|
|
3477
3477
|
},
|
|
3478
3478
|
{
|
|
3479
3479
|
"path": "engine/stage/capture.mjs",
|
|
3480
|
-
"sha256": "
|
|
3480
|
+
"sha256": "58698c9ecfd1eb789aabc9f166706492b1f1f00e1fa8d09449ea62f479ccd558"
|
|
3481
3481
|
},
|
|
3482
3482
|
{
|
|
3483
3483
|
"path": "engine/stage/clock.js",
|
|
@@ -3493,7 +3493,7 @@
|
|
|
3493
3493
|
},
|
|
3494
3494
|
{
|
|
3495
3495
|
"path": "engine/stage/gate.mjs",
|
|
3496
|
-
"sha256": "
|
|
3496
|
+
"sha256": "81fa8be4d4ad45fe9b799d5b6765923a0de4fa2507223a9e26faed3420112473"
|
|
3497
3497
|
},
|
|
3498
3498
|
{
|
|
3499
3499
|
"path": "engine/stage/loop-kit.js",
|
|
@@ -3509,15 +3509,15 @@
|
|
|
3509
3509
|
},
|
|
3510
3510
|
{
|
|
3511
3511
|
"path": "engine/stage/render.mjs",
|
|
3512
|
-
"sha256": "
|
|
3512
|
+
"sha256": "3f47ad9ed8e19763242af0b05359ae09031b76899939e170ef61bddbf9e261cf"
|
|
3513
3513
|
},
|
|
3514
3514
|
{
|
|
3515
3515
|
"path": "engine/stage/scan.mjs",
|
|
3516
|
-
"sha256": "
|
|
3516
|
+
"sha256": "0caaaeebcce7fd3b4d5c3ac5a1bf0f28d07c5c1f82202637a118941b309d3699"
|
|
3517
3517
|
},
|
|
3518
3518
|
{
|
|
3519
3519
|
"path": "engine/stage/stage-kit.js",
|
|
3520
|
-
"sha256": "
|
|
3520
|
+
"sha256": "72776c1fd3642c3925da48adbc342234a4e8599e46d49e89962b4d77c7e6a7d2"
|
|
3521
3521
|
},
|
|
3522
3522
|
{
|
|
3523
3523
|
"path": "engine/stills.mjs",
|
|
@@ -3741,7 +3741,7 @@
|
|
|
3741
3741
|
},
|
|
3742
3742
|
{
|
|
3743
3743
|
"path": "references/cover.md",
|
|
3744
|
-
"sha256": "
|
|
3744
|
+
"sha256": "93658c3f3a08b7a9c71443df92db418c6839396a389ac480bd7ccf22faf4eb54"
|
|
3745
3745
|
},
|
|
3746
3746
|
{
|
|
3747
3747
|
"path": "references/craft-loop.md",
|
|
@@ -3753,7 +3753,7 @@
|
|
|
3753
3753
|
},
|
|
3754
3754
|
{
|
|
3755
3755
|
"path": "references/runtime.md",
|
|
3756
|
-
"sha256": "
|
|
3756
|
+
"sha256": "15dc951960b2ae4b204aa094792d29e55829815894524556ebc00550655b325d"
|
|
3757
3757
|
},
|
|
3758
3758
|
{
|
|
3759
3759
|
"path": "references/sound.md",
|
|
@@ -3761,7 +3761,7 @@
|
|
|
3761
3761
|
},
|
|
3762
3762
|
{
|
|
3763
3763
|
"path": "references/stage.md",
|
|
3764
|
-
"sha256": "
|
|
3764
|
+
"sha256": "129c1ad6b67b629e8f7e022a705a289e889755f67532b311f19ff15cc7fa8251"
|
|
3765
3765
|
},
|
|
3766
3766
|
{
|
|
3767
3767
|
"path": "references/studio.md",
|