@litfamily/litgrok 1.0.30 → 1.0.32
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 +1 -1
- package/.grok/skills/lit-typographic-motion/references/stage.md +50 -0
- 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/hyperframes/package-lock.json +128 -5
- package/.grok/skills/readme-studio/templates/hyperframes/package.json +1 -1
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +8 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +1 -1
- package/docs/reference_ko-KR.md +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -203,7 +203,7 @@ An old agent name, prefixed or not, selects the renamed agent and prints that ro
|
|
|
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
205
|
|
|
206
|
-
<!-- litfamily:litgrok:start v=1.0.
|
|
206
|
+
<!-- litfamily:litgrok:start v=1.0.32 pre=0a -->
|
|
207
207
|
## LitGrok ground rules
|
|
208
208
|
|
|
209
209
|
Grok Build reads this file on every turn. These lines are the working agreement. The sections above still choose the skill.
|
|
@@ -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
|
|
@@ -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})`);
|
|
@@ -7,6 +7,7 @@ import { extname, join, sep } from 'node:path';
|
|
|
7
7
|
import { STAGE } from './constants.mjs';
|
|
8
8
|
import { launchStage } from './chrome.mjs';
|
|
9
9
|
import { SKILL_ROOT } from './fonts.mjs';
|
|
10
|
+
import { clipsScript } from './clips.mjs';
|
|
10
11
|
|
|
11
12
|
export const CONTENT_TYPES = Object.freeze({
|
|
12
13
|
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
|
@@ -78,6 +79,7 @@ export async function openStage({ executable, outDir, stageDir, width, height, f
|
|
|
78
79
|
const network = [];
|
|
79
80
|
const refused = [];
|
|
80
81
|
const served = new Set();
|
|
82
|
+
const clipsJs = clipsScript(stageDir);
|
|
81
83
|
const kit = existsSync(KIT_PATH) ? readFileSync(KIT_PATH) : Buffer.from('/* stage kit missing */');
|
|
82
84
|
const respond = (requestId, status, body, type) => browser.send('Fetch.fulfillRequest', {
|
|
83
85
|
requestId, responseCode: status,
|
|
@@ -95,6 +97,7 @@ export async function openStage({ executable, outDir, stageDir, width, height, f
|
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
99
|
if (url.pathname === '/lit/stage-kit.js') { served.add('/lit/stage-kit.js'); await respond(requestId, 200, kit, CONTENT_TYPES['.js']); return; }
|
|
100
|
+
if (clipsJs && url.pathname === '/lit/clips.js') { served.add('/lit/clips.js'); await respond(requestId, 200, clipsJs, CONTENT_TYPES['.js']); return; }
|
|
98
101
|
if (url.pathname.startsWith('/lit/three/')) {
|
|
99
102
|
const resolved = resolveThree(url.pathname);
|
|
100
103
|
if (resolved.refused) { refused.push(resolved.refused); await respond(requestId, 403, 'refused'); return; }
|
|
@@ -6,6 +6,7 @@ import { createHash } from 'node:crypto';
|
|
|
6
6
|
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { extname, join, relative } from 'node:path';
|
|
8
8
|
import { BlockedError, DIRECTOR, EXIT, STAGE } from './constants.mjs';
|
|
9
|
+
import { scanClips } from './clips.mjs';
|
|
9
10
|
import { resolveChrome, stageArgs } from './chrome.mjs';
|
|
10
11
|
import { fontPath, fontState, PINS, resolveFontKey } from './fonts.mjs';
|
|
11
12
|
import { FlashDetector } from './flash.mjs';
|
|
@@ -67,17 +68,24 @@ export function rasterInfo(bytes, ext) {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// Static scan before any launch: forbidden elements (17), absolute or protocol-relative URLs and
|
|
70
|
-
// preconnect/prefetch links (19), raster limits, flipbooks and animated rasters (17).
|
|
71
|
-
|
|
71
|
+
// preconnect/prefetch links (19), raster limits, flipbooks and animated rasters (17). Frames of a clip
|
|
72
|
+
// declared in clips/clips.json are checked by scanClips instead; `clips: false` (cover mode) ignores the
|
|
73
|
+
// declaration, so a declared folder is held to the raster limits like any other.
|
|
74
|
+
export function scanStage(stageDir, { clips: allowClips = true } = {}) {
|
|
72
75
|
const contract = [];
|
|
73
76
|
const network = [];
|
|
74
77
|
const warnings = [];
|
|
75
|
-
if (!existsSync(join(stageDir, 'index.html'))) return { contract: ['stage/index.html is missing'], network, warnings, files: [], rasters: [] };
|
|
78
|
+
if (!existsSync(join(stageDir, 'index.html'))) return { contract: ['stage/index.html is missing'], network, warnings, files: [], rasters: [], clips: [] };
|
|
76
79
|
const files = walk(stageDir);
|
|
77
80
|
// A Blender plate and its preview stills are frames the runner wrote and plate.json lists by name
|
|
78
81
|
// and size; they are not page rasters. Anything not listed is held to the raster limits.
|
|
79
82
|
const plates = [readPlate(stageDir, 'plate'), readPlate(stageDir, 'plate-preview')].filter(Boolean);
|
|
80
83
|
const listed = new Set(plates.flatMap((p) => p.frames.map((f) => `${p.dir}/${String(f.name)}`)));
|
|
84
|
+
let clips = [];
|
|
85
|
+
if (allowClips) {
|
|
86
|
+
try { clips = scanClips(stageDir); } catch (error) { contract.push(error.message); }
|
|
87
|
+
for (const clip of clips) for (const rel of clip.frames) listed.add(rel);
|
|
88
|
+
}
|
|
81
89
|
const rasters = [];
|
|
82
90
|
for (const file of files) {
|
|
83
91
|
if (file.symlink) continue;
|
|
@@ -112,7 +120,7 @@ export function scanStage(stageDir) {
|
|
|
112
120
|
const bySize = new Map();
|
|
113
121
|
for (const r of rasters) { const key = `${r.width}x${r.height}`; bySize.set(key, (bySize.get(key) ?? 0) + 1); }
|
|
114
122
|
for (const [size, count] of bySize) if (count >= STAGE.flipbookCount) contract.push(`${count} rasters of one size (${size}) form a flipbook; rasters are textures and stills, never a frame sequence`);
|
|
115
|
-
return { contract, network, warnings, files: files.map((f) => f.rel), rasters };
|
|
123
|
+
return { contract, network, warnings, files: files.map((f) => f.rel), rasters, clips };
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
// ---- fonts -----------------------------------------------------------------------------------
|
|
@@ -424,6 +432,7 @@ export async function renderStage({ out, treatment, round = 1, stillsOnly = fals
|
|
|
424
432
|
schemaVersion: 1, path: 'stage', format: treatment.format, resolution: [width, height], fps, durationSec: totalFrames / fps, targetDurationSec: treatment.durationSec,
|
|
425
433
|
frames: totalFrames, seed, rung: masterRung, renderer: `${masterRenderer} on the ${masterRung} rung (stage path)`, softwareRenderer: masterRung === 'software' || isSoftwareRenderer(masterRenderer), chromeFlags: masterArgs.filter((arg) => !arg.startsWith('--user-data-dir') && !arg.startsWith('--window-size') && arg !== 'about:blank'), previewEncoder: preview.encoder,
|
|
426
434
|
sound: sound.summary, round, warnings,
|
|
435
|
+
clips: scan.clips.map((c) => ({ id: c.id, frames: c.count, fps: c.fps, width: c.width, height: c.height, bytes: c.bytes })),
|
|
427
436
|
};
|
|
428
437
|
const stills = writeStills(out, { plan, frames: kept, width, height, path: 'stage', round, mode: 'full', poster: { bytes: posterBytes, frame: posterFrame }, treatmentSha256 });
|
|
429
438
|
// The final render ships its storyboard too: the done-check wants the board viewed on the final set.
|
|
@@ -280,6 +280,49 @@
|
|
|
280
280
|
};
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// A declared keyed clip (stage/clips/clips.json, served as window.LitClips by /lit/clips.js): one
|
|
284
|
+
// wrapper at `box` over the film, holding the clip's frame for t. The frame is a pure function of film
|
|
285
|
+
// time, floor((t - start) * fps) + 1 within from..to, so no <video> and no real clock is involved.
|
|
286
|
+
// Before `start` and after `to` the wrapper is hidden (null) unless `hold` keeps the last frame or
|
|
287
|
+
// `loop` wraps by frame index. The wrapper is what a page scales or fades; the <img> inside is
|
|
288
|
+
// replaced on a frame change, so the renderer waits for its decode before the capture.
|
|
289
|
+
function overlay(opts) {
|
|
290
|
+
var all = root.LitClips, parent = opts && opts.parent, id = opts && opts.id, box = opts && opts.box;
|
|
291
|
+
if (!all) throw new Error('LitStage.overlay: load <script src="/lit/clips.js"></script> before the page script; the stage declares no clips (clips/clips.json)');
|
|
292
|
+
var c = all[id];
|
|
293
|
+
if (!c) throw new Error('LitStage.overlay: no clip "' + id + '" in clips/clips.json (declared: ' + Object.keys(all).join(', ') + ')');
|
|
294
|
+
if (!parent || !box || !(box.w > 0) || !(box.h > 0)) throw new TypeError('LitStage.overlay: pass { parent, id, box: { x, y, w, h } }');
|
|
295
|
+
var doc = parent.ownerDocument || root.document;
|
|
296
|
+
var wrap = doc.createElement('div');
|
|
297
|
+
wrap.setAttribute('data-lit-clip', id);
|
|
298
|
+
var ws = wrap.style;
|
|
299
|
+
ws.position = 'absolute'; ws.left = box.x + 'px'; ws.top = box.y + 'px'; ws.width = box.w + 'px'; ws.height = box.h + 'px';
|
|
300
|
+
ws.display = 'none'; ws.pointerEvents = 'none';
|
|
301
|
+
parent.append(wrap);
|
|
302
|
+
var shown = 0, img = null;
|
|
303
|
+
return function (t, o) {
|
|
304
|
+
o = o || {};
|
|
305
|
+
var start = o.start == null ? 0 : o.start, from = o.from == null ? 1 : o.from, to = o.to == null ? c.count : o.to;
|
|
306
|
+
if (!(from >= 1 && to <= c.count && from <= to)) throw new RangeError('LitStage.overlay: from..to must lie within 1..' + c.count + ' (got ' + from + '..' + to + ')');
|
|
307
|
+
var n = to - from + 1, k = Math.floor((t - start) * c.fps + 1e-6);
|
|
308
|
+
var i = k < 0 ? 0 : k < n ? from + k : o.loop ? from + (k % n) : o.hold ? to : 0;
|
|
309
|
+
wrap.style.display = i ? 'block' : 'none';
|
|
310
|
+
if (!i) return null;
|
|
311
|
+
if (i !== shown) {
|
|
312
|
+
var next = doc.createElement('img');
|
|
313
|
+
next.src = c.src + '/frame_' + String(i).padStart(4, '0') + '.png';
|
|
314
|
+
next.alt = '';
|
|
315
|
+
var ns = next.style;
|
|
316
|
+
ns.display = 'block'; ns.width = '100%'; ns.height = '100%'; ns.objectFit = 'contain';
|
|
317
|
+
if (img) img.replaceWith(next);
|
|
318
|
+
else wrap.append(next);
|
|
319
|
+
img = next;
|
|
320
|
+
shown = i;
|
|
321
|
+
}
|
|
322
|
+
return wrap;
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
283
326
|
function rgb(hex) {
|
|
284
327
|
var h = String(hex).replace('#', '');
|
|
285
328
|
if (h.length === 3) h = h.split('').map(function (c) { return c + c; }).join('');
|
|
@@ -472,6 +515,7 @@
|
|
|
472
515
|
clip: clip,
|
|
473
516
|
circle: circle,
|
|
474
517
|
plate: plate,
|
|
518
|
+
overlay: overlay,
|
|
475
519
|
mix: mix,
|
|
476
520
|
rgb: rgb,
|
|
477
521
|
hex: hex,
|