@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.
@@ -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.30 pre=0a -->
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
- export function scanStage(stageDir) {
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,