@litfamily/lithermes 1.0.33 → 1.0.34

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/README.md CHANGED
@@ -1,14 +1,14 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: seven armored robots power on one by one, the LitHermes robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: seven armored robots power on one by one, the LitHermes robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  <p align="center">
4
- <a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/badge-version.svg" alt="1.0.33" /></a>
5
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/badge-license.svg" alt="MIT license" /></a>
4
+ <a href="#install"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/badge-version.svg" alt="1.0.34" /></a>
5
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/badge-license.svg" alt="MIT license" /></a>
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a> &nbsp;
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition film</a> &nbsp;
11
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
9
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/lucide-book-open.svg" width="16" alt="" /> Guide</a> &nbsp;
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition film</a> &nbsp;
11
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
12
12
  </p>
13
13
 
14
14
  # LitHermes
@@ -17,7 +17,7 @@
17
17
 
18
18
  A plugin for **Hermes Agent**. Put `lit` in front of a request, and Hermes plans the task, does it, checks the result and writes down where things stand, so the next session can pick up where this one stopped.
19
19
 
20
- **[README and docs on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/README_Ko-KR.md)
20
+ **[README and docs on GitHub](https://github.com/wjgoarxiv/lithermes#readme)** · [한국어](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/README_Ko-KR.md)
21
21
 
22
22
  ## Install
23
23
 
package/README_Ko-KR.md CHANGED
@@ -1,14 +1,14 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 일곱 로봇 패널이 차례로 켜지고, LitHermes 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 일곱 로봇 패널이 차례로 켜지고, LitHermes 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  <p align="center">
4
- <a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/badge-version.svg" alt="1.0.33" /></a>
5
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/badge-license.svg" alt="MIT license" /></a>
4
+ <a href="#설치"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/badge-version.svg" alt="1.0.34" /></a>
5
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/badge-license.svg" alt="MIT license" /></a>
6
6
  </p>
7
7
 
8
8
  <p align="center">
9
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a> &nbsp;
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition 영상</a> &nbsp;
11
- <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
9
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/docs/guide.ko.md"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/lucide-book-open.svg" width="16" alt="" /> 안내</a> &nbsp;
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/ignition-film.mp4"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/lucide-play.svg" width="16" alt="" /> Ignition 영상</a> &nbsp;
11
+ <a href="https://github.com/wjgoarxiv/lithermes/blob/main/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/readme-assets/lucide-shield-check.svg" width="16" alt="" /> MIT</a>
12
12
  </p>
13
13
 
14
14
  # LitHermes
@@ -17,7 +17,7 @@
17
17
 
18
18
  **Hermes Agent**용 플러그인입니다. 요청 앞에 `lit`을 붙이면 Hermes가 작업을 계획하고, 해내고, 결과를 확인한 뒤 어디까지 왔는지 적어 둡니다. 그래서 다음 세션이 그 기록을 읽고 멈춘 자리에서 이어 갈 수 있습니다.
19
19
 
20
- **[GitHub의 README와 문서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.33/README.md)
20
+ **[GitHub의 README와 문서](https://github.com/wjgoarxiv/lithermes/blob/main/README_Ko-KR.md)** · [English](https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.34/README.md)
21
21
 
22
22
  ## 설치
23
23
 
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-10-31T04:39:04.686Z",
2
+ "syncedAt": "2026-11-01T12:25:40.311Z",
3
3
  "source": "bundled-payload",
4
- "sourceHash": "eb3d5233f206bc088a614b8feedd37be49b298e4c9bb8de4d8ade6bd5805bc3f",
4
+ "sourceHash": "2301b868931c86d3bfeecdd04559f48bea1a0d8edf65879d4c3720755b023e07",
5
5
  "files": [
6
6
  {
7
7
  "path": "NOTICE.md",
@@ -181,7 +181,7 @@
181
181
  },
182
182
  {
183
183
  "path": "plugin.yaml",
184
- "sha256": "52de503a4b2026a261e4ae4abe8df3bf107ac6833052537d53b601536dc963e1"
184
+ "sha256": "d3a858b2ae8b43fe9010b8078777737ab2bb6eae3f32b38b44043df7454e381d"
185
185
  },
186
186
  {
187
187
  "path": "provider_metrics.py",
@@ -1329,7 +1329,7 @@
1329
1329
  },
1330
1330
  {
1331
1331
  "path": "skills/frontend-ui-ux/references/complete-contract.md",
1332
- "sha256": "eae55bedc712a6c6ceba08b624324dc323addf9d4203c5e2b19f3b6ac37c7f34"
1332
+ "sha256": "fae8c15d7256482e79a48444972ca1b91ae8ea723d362521648f74b286c12e9a"
1333
1333
  },
1334
1334
  {
1335
1335
  "path": "skills/frontend-ui-ux/references/composition.md",
@@ -4329,7 +4329,7 @@
4329
4329
  },
4330
4330
  {
4331
4331
  "path": "skills/lit-typographic-motion/SKILL.md",
4332
- "sha256": "ff4c4f987a7762fce2d33cdc8ef86de670284310a843da9854a00bcaf70b6dd6"
4332
+ "sha256": "a1da7728a3f998c05150a23426be0b7e1a0e000e58ffe5a24289d50a71b8370e"
4333
4333
  },
4334
4334
  {
4335
4335
  "path": "skills/lit-typographic-motion/bin/audio.py",
@@ -4417,7 +4417,7 @@
4417
4417
  },
4418
4418
  {
4419
4419
  "path": "skills/lit-typographic-motion/engine/cover.mjs",
4420
- "sha256": "7bd6e2ff73ebb0525bf416f8685a36236316443631217245055c68668cfaa15f"
4420
+ "sha256": "0a112972eacbc6b18f7ca1765a26a5044c4b5c76dfd4812338ae7486f831c2d9"
4421
4421
  },
4422
4422
  {
4423
4423
  "path": "skills/lit-typographic-motion/engine/director.mjs",
@@ -4485,7 +4485,7 @@
4485
4485
  },
4486
4486
  {
4487
4487
  "path": "skills/lit-typographic-motion/engine/stage-gate.mjs",
4488
- "sha256": "fd3ddea38388d93a87fb3b880b834002a49b937a6705b929a290cb31e784ea06"
4488
+ "sha256": "7ce876d61ed0cbee9b2e3f847e9da0257113572737b1f7c76649632a2a31ff9d"
4489
4489
  },
4490
4490
  {
4491
4491
  "path": "skills/lit-typographic-motion/engine/stage-init.js",
@@ -4493,7 +4493,7 @@
4493
4493
  },
4494
4494
  {
4495
4495
  "path": "skills/lit-typographic-motion/engine/stage-kit.js",
4496
- "sha256": "4dc0083faf542f9b873d866438549f4f618896acbefa7b51797f44b00552931b"
4496
+ "sha256": "b33f71648fd897c22c119841c1b43226cb11bb5ad855e85040a1dc8d81ca66e2"
4497
4497
  },
4498
4498
  {
4499
4499
  "path": "skills/lit-typographic-motion/engine/stage-qa.mjs",
@@ -4505,7 +4505,7 @@
4505
4505
  },
4506
4506
  {
4507
4507
  "path": "skills/lit-typographic-motion/engine/stage.mjs",
4508
- "sha256": "fb31e467eed069d6110d121e31825d314b7bf4e8d478aea30bb04d99e8fbed34"
4508
+ "sha256": "03165bde6fdb74ca8ad29e38a835629f672c272cb1e88d43c1c2f64914849a8f"
4509
4509
  },
4510
4510
  {
4511
4511
  "path": "skills/lit-typographic-motion/engine/stills.mjs",
@@ -4637,7 +4637,7 @@
4637
4637
  },
4638
4638
  {
4639
4639
  "path": "skills/lit-typographic-motion/references/stage.md",
4640
- "sha256": "555e788a408d41e74ce217a63507d096a4d945419e79f33cdf05cc4af236dca8"
4640
+ "sha256": "c02bd6c8373bd684b58ae3f92c178418353465cbefabf310292c17cf8f91490b"
4641
4641
  },
4642
4642
  {
4643
4643
  "path": "skills/lit-typographic-motion/references/studio.md",
@@ -4941,7 +4941,7 @@
4941
4941
  },
4942
4942
  {
4943
4943
  "path": "skills/visual-qa/references/complete-contract.md",
4944
- "sha256": "c27ea8291007970ab513d9cca426538840dc6c24a4cfcb06c266765ecb5f6884"
4944
+ "sha256": "393cc99a6fabb2fc40d8d77075833747580e2553614b552012ca1d22acc9e7e5"
4945
4945
  },
4946
4946
  {
4947
4947
  "path": "skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json",
@@ -1,5 +1,5 @@
1
1
  name: lithermes
2
- version: 1.0.33
2
+ version: 1.0.34
3
3
  description: "Hermes-native workflow toolkit: litgoal durable runtime, 5-lane review orchestrator, Litwork commands, skills, and prompt steering."
4
4
  author: "LitHermes contributors"
5
5
  kind: standalone
@@ -445,7 +445,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
445
445
 
446
446
  ```text
447
447
  $ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
448
- Installed LitHermes 1.0.33
448
+ Installed LitHermes 1.0.34
449
449
  plugin: $ISOLATED/plugins/lithermes
450
450
  model config: updated
451
451
 
@@ -462,7 +462,7 @@ installed payload: PASS
462
462
  enabled config: PASS
463
463
 
464
464
  $ hermes lithermes doctor
465
- [OK] plugin.yaml readable (version 1.0.33)
465
+ [OK] plugin.yaml readable (version 1.0.34)
466
466
  [OK] skills bundled: 40
467
467
  [OK] litgoal durable runtime importable
468
468
  ```
@@ -64,7 +64,7 @@ Done only when `$M complete --out DIR` exits 0: valid treatment, passing gate (a
64
64
 
65
65
  ## #contract.hard_stops
66
66
 
67
- Exits 10, 11, 12, 14, 15: name the state and its fix (`references/runtime.md`). 16 the treatment field, 17 a stage break, 18 a nondeterministic frame, 19 a network request, 20 a sound fault, 21 no cover encoder, 22 a missing cover asset. Never install during a render, deliver a flash-failing film, or downgrade to pass.
67
+ Exits 10, 11, 12, 14, 15: name the state and its fix (`references/runtime.md`). 16 the treatment field, 17 a stage or clip break, 18 a nondeterministic frame, 19 a network request, 20 a sound fault, 21 no cover encoder, 22 a missing cover asset. Never install during a render, deliver a flash-failing film, or downgrade to pass.
68
68
 
69
69
  ## #contract.anti_patterns
70
70
 
@@ -102,7 +102,7 @@ function prepare(out, env) {
102
102
  recordFirstTreatment(out, plan.treatment);
103
103
  const coverDir = join(out, COVER_DIR);
104
104
  if (!existsSync(join(coverDir, 'index.html'))) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: no ${COVER_DIR}/index.html in ${out}; write the cover page first (references/cover.md)`);
105
- const scan = scanStage(coverDir);
105
+ const scan = scanStage(coverDir, { clips: false });
106
106
  if (scan.contract.length) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${scan.contract.join('; ')}`);
107
107
  if (scan.network.length) throw fail(EXIT.STAGE_NETWORK_REQUEST, `STAGE_NETWORK_REQUEST: ${scan.network.join('; ')}; a cover is served offline`);
108
108
  const assets = checkAssets({ out, recipe: plan.treatment.recipe, rasters: scan.rasters.paths, coverDir: COVER_DIR });
@@ -85,6 +85,7 @@ export function renderStageReport(manifest, gate, { out, previewName }) {
85
85
  lines.push(`path: stage; format ${manifest.format} (${manifest.resolution.join('x')}); ${manifest.durationSec.toFixed(2)} s at ${manifest.fps} fps (${manifest.totalFrames} frames); treatment ${manifest.targetDurationSec} s`);
86
86
  lines.push(`renderer: ${manifest.renderer}; flags: ${manifest.chromeFlags.join(' ')}`);
87
87
  lines.push(`frame time: p50 ${manifest.frameTimeMs.p50} ms, p95 ${manifest.frameTimeMs.p95} ms over ${manifest.frameTimeMs.captured} captures; master ${manifest.masterSec?.toFixed(1)} s`);
88
+ if (manifest.scan?.clips?.length) lines.push(`clips: ${manifest.scan.clips.map((c) => `${c.id} ${c.count} frames at ${c.fps} fps`).join(', ')} (keyed overlays, chosen from film time)`);
88
89
  if (manifest.sound) lines.push(`sound: ${manifest.sound.label}`);
89
90
  lines.push(`QA gate: ${gate.failed.length ? 'FAIL' : 'PASS'}`);
90
91
  for (const rule of gate.rules) lines.push(`${rule.id}: ${rule.status} — ${rule.detail}`);
@@ -1,7 +1,7 @@
1
1
  // LitHermes stage kit, served to stage pages at /lit/stage-kit.js.
2
2
  // Motion primitives only: easing, springs, keyframes, timing, seeded
3
- // randomness, text splitting, path drawing and morphing, masks, colour mixing
4
- // and text registration. It ships no scene, object, layout or copy; the page
3
+ // randomness, text splitting, path drawing and morphing, masks, colour mixing,
4
+ // text registration and declared keyed clips. It ships no scene, object, layout or copy; the page
5
5
  // draws whatever its treatment calls for. Every function is a pure function of
6
6
  // its arguments (and of t), so a frame depends only on the virtual clock.
7
7
  (() => {
@@ -374,6 +374,45 @@
374
374
  return target;
375
375
  }
376
376
 
377
+ // A declared keyed clip (stage/clips/clips.json, served as window.LitClips by /lit/clips.js). One absolutely
378
+ // placed wrapper at `box` holds the clip's frame for t; the frame index is floor((t - start) * fps) + 1 counted
379
+ // from `from`, a pure function of film time, so no <video> and no real clock is involved. Outside from..to the
380
+ // wrapper is hidden (null returned) unless `hold` keeps the last frame or `loop` wraps by frame index. The page
381
+ // scales or fades the returned wrapper; the <img> inside is replaced on a frame change and the renderer waits
382
+ // for its decode before it captures.
383
+ function clip({ parent, id, box } = {}) {
384
+ const all = window.LitClips;
385
+ 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)');
386
+ const c = all[id];
387
+ if (!c) throw new Error(`LitStage.clip: no clip "${id}" in clips/clips.json (declared: ${Object.keys(all).join(', ')})`);
388
+ if (!parent || !box || !(box.w > 0) || !(box.h > 0)) throw new Error('LitStage.clip: needs { parent, id, box: { x, y, w, h } }');
389
+ const wrap = document.createElement('div');
390
+ wrap.setAttribute('data-lit-clip', id);
391
+ 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' });
392
+ parent.append(wrap);
393
+ let shown = 0;
394
+ let img = null;
395
+ return (t, { start = 0, from = 1, to = c.count, loop = false, hold = false } = {}) => {
396
+ if (!(from >= 1 && to <= c.count && from <= to)) throw new Error(`LitStage.clip: from..to must lie within 1..${c.count} (got ${from}..${to})`);
397
+ const n = to - from + 1;
398
+ const k = Math.floor((t - start) * c.fps + 1e-6);
399
+ const i = k < 0 ? 0 : k < n ? from + k : loop ? from + (k % n) : hold ? to : 0;
400
+ wrap.style.display = i ? 'block' : 'none';
401
+ if (!i) return null;
402
+ if (i !== shown) {
403
+ const next = document.createElement('img');
404
+ next.src = `${c.src}/frame_${String(i).padStart(4, '0')}.png`;
405
+ next.alt = '';
406
+ Object.assign(next.style, { display: 'block', width: '100%', height: '100%', objectFit: 'contain' });
407
+ if (img) img.replaceWith(next);
408
+ else wrap.append(next);
409
+ img = next;
410
+ shown = i;
411
+ }
412
+ return wrap;
413
+ };
414
+ }
415
+
377
416
  function define(film) {
378
417
  if (!film || typeof film !== 'object') throw new Error('LitStage.define needs { width, height, fps, duration, render }');
379
418
  for (const key of ['width', 'height', 'duration']) if (!Number.isFinite(film[key])) throw new Error(`LitStage.define: ${key} must be a number`);
@@ -384,6 +423,6 @@
384
423
 
385
424
  window.LitStage = Object.freeze({
386
425
  ease, bezier, spring, kf, at, stagger, seq, rand, splitText, drawPath, morph,
387
- clipInset, clipCircle, maskWipe, mix, text, define,
426
+ clipInset, clipCircle, maskWipe, mix, text, define, clip,
388
427
  });
389
428
  })();
@@ -22,6 +22,7 @@ import { runTextQa } from './stage-qa.mjs';
22
22
  import { readStyle, styleCss, styleScript } from './styles.mjs';
23
23
  import { addToStills, writeBoard } from './board.mjs';
24
24
  import { FONT_FILES } from './frame.mjs';
25
+ import { decodePng } from './cover-png.mjs';
25
26
 
26
27
  import { findCache, fontFile, fontState, hangulPath, runtimeRequire, WARM } from '../bin/runtime.mjs';
27
28
 
@@ -54,6 +55,12 @@ export const CONTENT_TYPES = Object.freeze({
54
55
  const RASTER = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
55
56
  const TEXTUAL = new Set(['.html', '.js', '.mjs', '.css', '.svg', '.json']);
56
57
  export const RASTER_LIMITS = Object.freeze({ count: 24, bytes: 8 * 1024 * 1024, sameSize: 10 });
58
+ // Declared keyed clips (references/stage.md, Clips) answer to these caps instead of RASTER_LIMITS. A clip
59
+ // is a sticker on the film, never the film: 8-bit RGBA PNG frames, at most `side` px on the long side.
60
+ export const CLIP_LIMITS = Object.freeze({
61
+ clips: 12, framesPerClip: 360, framesTotal: 1500, bytesPerClip: 40 * 1024 * 1024, bytesTotal: 128 * 1024 * 1024, side: 720, fps: 30,
62
+ });
63
+ export const CLIPS_DIR = 'clips';
57
64
 
58
65
  // The software rung only (SwiftShader, CPU raster): a mismatch between two
59
66
  // captures can then only come from the page. Plus the stage set and the two
@@ -171,14 +178,130 @@ function walk(dir, root, out = []) {
171
178
  return out;
172
179
  }
173
180
 
174
- export function scanStage(stageDir) {
181
+ // ---------------------------------------------------------------------------
182
+ // Declared clips: stage/clips/clips.json and the frame folders it names.
183
+
184
+ const CLIP_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/u;
185
+ const CLIP_DIR = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u;
186
+ const clipFrame = (i) => `frame_${String(i).padStart(4, '0')}.png`;
187
+ const mb = (bytes) => (bytes / 1048576).toFixed(1);
188
+
189
+ // The clips clips.json declares ([] without the file), checked for shape. Throws a plain Error naming the break.
190
+ function declaredClips(root, limits = CLIP_LIMITS) {
191
+ const file = join(root, CLIPS_DIR, 'clips.json');
192
+ if (!existsSync(file)) return [];
193
+ let doc;
194
+ try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw new Error(`${CLIPS_DIR}/clips.json is not valid JSON (${error.message})`); }
195
+ if (!doc || !Array.isArray(doc.clips)) throw new Error(`${CLIPS_DIR}/clips.json needs { "clips": [ { "id", "dir", "count", "fps" } ] }`);
196
+ if (doc.clips.length > limits.clips) throw new Error(`${doc.clips.length} clips declared (at most ${limits.clips} clips)`);
197
+ const ids = new Set(), dirs = new Set();
198
+ return doc.clips.map((c, n) => {
199
+ const at = `${CLIPS_DIR}/clips.json clip #${n + 1}`;
200
+ if (!c || typeof c !== 'object') throw new Error(`${at} is not an object`);
201
+ if (typeof c.id !== 'string' || !CLIP_ID.test(c.id)) throw new Error(`${at}: id must be a plain token of letters, digits, - and _ (got ${JSON.stringify(c.id)})`);
202
+ if (ids.has(c.id)) throw new Error(`${at}: duplicate id ${c.id}`);
203
+ ids.add(c.id);
204
+ if (typeof c.dir !== 'string' || !CLIP_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)})`);
205
+ if (dirs.has(c.dir)) throw new Error(`clip "${c.id}": dir ${c.dir} is already declared by another clip`);
206
+ dirs.add(c.dir);
207
+ 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)})`);
208
+ 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)})`);
209
+ return { id: c.id, dir: c.dir, count: c.count, fps: c.fps };
210
+ });
211
+ }
212
+
213
+ // One declared clip against its bytes: exactly frame_0001.png..frame_<count>.png in clips/<dir>/, each an 8-bit
214
+ // non-animated RGBA PNG of one size within the overlay cap, real transparency in the first, middle and last frame.
215
+ function checkClip(d, byRel, limits) {
216
+ const fail = (message) => new Error(`clip "${d.id}": ${message}`);
217
+ if (d.count > limits.framesPerClip) throw fail(`${d.count} frames (at most ${limits.framesPerClip} per clip)`);
218
+ const names = [];
219
+ for (let i = 1; i <= d.count; i++) {
220
+ const rel = join(CLIPS_DIR, d.dir, clipFrame(i));
221
+ if (!byRel.has(rel)) throw fail(`${CLIPS_DIR}/${d.dir}/${clipFrame(i)} is missing (declared ${d.count} frames, named frame_0001.png onward)`);
222
+ names.push(rel);
223
+ }
224
+ const prefix = join(CLIPS_DIR, d.dir) + sep;
225
+ const stray = [...byRel.keys()].find((rel) => rel.startsWith(prefix) && !names.includes(rel));
226
+ if (stray) throw fail(`${stray.split(sep).join('/')} is not one of the ${d.count} declared frames; raise count or remove it`);
227
+ const bytes = names.reduce((sum, rel) => sum + statSync(byRel.get(rel)).size, 0);
228
+ if (bytes > limits.bytesPerClip) throw fail(`${mb(bytes)} MB (at most ${(limits.bytesPerClip / 1048576).toFixed(0)} MB per clip)`);
229
+ let size = null;
230
+ for (const rel of names) {
231
+ const data = readFileSync(byRel.get(rel));
232
+ const shown = rel.split(sep).join('/');
233
+ if (data.length < 33 || data.readUInt32BE(12) !== 0x49484452 || data.toString('latin1', 1, 4) !== 'PNG') throw fail(`${shown} is not a readable PNG`);
234
+ if (isAnimatedRaster(data, '.png')) throw fail(`${shown} is an animated image; a clip is a folder of still frames`);
235
+ if (data[25] !== 6 || data[24] !== 8) throw fail(`${shown} must be an 8-bit RGBA PNG with an alpha channel (a keyed frame), not an opaque image`);
236
+ const [width, height] = imageSize(data, '.png');
237
+ if (Math.max(width, height) > limits.side) throw fail(`${shown} is ${width}x${height}; a clip is an overlay of at most ${limits.side} px on the long side`);
238
+ if (size && (size[0] !== width || size[1] !== height)) throw fail(`${shown} is ${width}x${height} but frame_0001.png is ${size[0]}x${size[1]}; one size per clip`);
239
+ size = [width, height];
240
+ }
241
+ for (const rel of [names[0], names[Math.floor((names.length - 1) / 2)], names[names.length - 1]]) {
242
+ let rgba;
243
+ try { ({ rgba } = decodePng(readFileSync(byRel.get(rel)))); } catch (error) { throw fail(`${rel.split(sep).join('/')} cannot be decoded (${error.message}); use a non-interlaced 8-bit RGBA PNG`); }
244
+ let transparent = false;
245
+ for (let i = 3; i < rgba.length && !transparent; i += 4) transparent = rgba[i] < 255;
246
+ if (!transparent) throw fail(`${rel.split(sep).join('/')} has no transparent pixel; a clip is keyed, not a full-frame video`);
247
+ }
248
+ return { ...d, width: size[0], height: size[1], bytes, frames: names };
249
+ }
250
+
251
+ /**
252
+ * Check every declared clip. Never throws: returns { clips, problems, claimed }, where `clips` are the clips that
253
+ * passed ({ id, dir, count, fps, width, height, bytes, frames }), `problems` are the contract findings, and
254
+ * `claimed` is the set of stage-relative raster paths inside declared clip folders, which are not page rasters.
255
+ */
256
+ export function scanClips(stageDir, { limits = CLIP_LIMITS, files = null } = {}) {
257
+ const root = realpathSync(stageDir);
258
+ const problems = [];
259
+ let decls;
260
+ try { decls = declaredClips(root, limits); } catch (error) { return { clips: [], problems: [error.message], claimed: new Set() }; }
261
+ const byRel = new Map();
262
+ for (const f of files || walk(root, root)) if (!f.escape && RASTER.has(extname(f.path).toLowerCase())) byRel.set(relative(root, f.path), f.real);
263
+ const claimed = new Set();
264
+ const clips = [];
265
+ let frames = 0, bytes = 0;
266
+ for (const d of decls) {
267
+ const prefix = join(CLIPS_DIR, d.dir) + sep;
268
+ const mine = new Map([...byRel].filter(([rel]) => rel.startsWith(prefix)));
269
+ for (const rel of mine.keys()) claimed.add(rel);
270
+ try { clips.push(checkClip(d, mine, limits)); } catch (error) { problems.push(error.message); }
271
+ }
272
+ for (const c of clips) { frames += c.count; bytes += c.bytes; }
273
+ if (frames > limits.framesTotal) problems.push(`${frames} clip frames in all (at most ${limits.framesTotal})`);
274
+ if (bytes > limits.bytesTotal) problems.push(`${mb(bytes)} MB of clip frames in all (at most ${(limits.bytesTotal / 1048576).toFixed(0)} MB)`);
275
+ return { clips, problems, claimed };
276
+ }
277
+
278
+ /**
279
+ * window.LitClips for LitStage.clip: id -> { src, count, fps, width, height }, from clips.json and the first frame's
280
+ * header. null when the stage declares no clips, or declares ones the scan would refuse.
281
+ */
282
+ export function clipsScript(stageDir) {
283
+ try {
284
+ const root = realpathSync(stageDir);
285
+ const table = Object.fromEntries(declaredClips(root).map((c) => {
286
+ const [width, height] = imageSize(readFileSync(join(root, CLIPS_DIR, c.dir, clipFrame(1))), '.png');
287
+ return [c.id, { src: `${CLIPS_DIR}/${c.dir}`, count: c.count, fps: c.fps, width, height }];
288
+ }));
289
+ return Object.keys(table).length ? `window.LitClips = ${JSON.stringify(table)};\n` : null;
290
+ } catch { return null; }
291
+ }
292
+
293
+ // `clips: false` (cover mode) ignores clips.json, so a clip folder is held to the raster limits.
294
+ export function scanStage(stageDir, { clips: allowClips = true } = {}) {
175
295
  const root = realpathSync(stageDir);
176
296
  const contract = [], network = [];
177
297
  const files = walk(root, root);
298
+ const clipScan = allowClips ? scanClips(root, { files }) : { clips: [], problems: [], claimed: new Set() };
299
+ contract.push(...clipScan.problems);
178
300
  const rasters = [];
179
301
  for (const file of files) {
180
302
  const rel = relative(root, file.path);
181
303
  if (file.escape) { contract.push(`${rel} is a symlink that leaves the stage dir`); continue; }
304
+ if (clipScan.claimed.has(rel)) continue;
182
305
  const ext = extname(file.path).toLowerCase();
183
306
  const bytes = readFileSync(file.real);
184
307
  if (RASTER.has(ext)) {
@@ -199,7 +322,8 @@ export function scanStage(stageDir) {
199
322
  const bySize = new Map();
200
323
  for (const r of rasters) if (r.size) { const key = r.size.join('x'); bySize.set(key, (bySize.get(key) || 0) + 1); }
201
324
  for (const [key, count] of bySize) if (count >= RASTER_LIMITS.sameSize) contract.push(`${count} rasters of the same size ${key} read as a flipbook; draw motion, do not play frames`);
202
- return { contract: [...new Set(contract)], network: [...new Set(network)], files: files.length, rasters: { count: rasters.length, bytes: total, paths: rasters.map((r) => r.rel) } };
325
+ const clips = clipScan.clips.map(({ id, count, fps, width, height, bytes }) => ({ id, count, fps, width, height, bytes }));
326
+ return { contract: [...new Set(contract)], network: [...new Set(network)], files: files.length, rasters: { count: rasters.length, bytes: total, paths: rasters.map((r) => r.rel) }, clips };
203
327
  }
204
328
 
205
329
  // ---------------------------------------------------------------------------
@@ -312,6 +436,7 @@ export async function openStage({ runtime, stageDir, profileRoot, width, height,
312
436
  const cdp = await context.newCDPSession(page);
313
437
  const root = realpathSync(stageDir);
314
438
  const kit = readFileSync(join(here, 'stage-kit.js'));
439
+ const clipsJs = clipsScript(root);
315
440
  const blocked = [], contract = [];
316
441
  const serve = async (event) => {
317
442
  const { requestId, request } = event;
@@ -329,6 +454,7 @@ export async function openStage({ runtime, stageDir, profileRoot, width, height,
329
454
  const path = decodeURIComponent(url.pathname);
330
455
  if (path === '/lit/stage-kit.js') return reply(200, CONTENT_TYPES['.js'], kit);
331
456
  if (path === '/lit/fonts.css') return reply(200, CONTENT_TYPES['.css'], fontCss);
457
+ if (path === '/lit/clips.js' && clipsJs) return reply(200, CONTENT_TYPES['.js'], clipsJs);
332
458
  if (path === '/lit/style.css' && style) return reply(200, CONTENT_TYPES['.css'], style.css);
333
459
  if (path === '/lit/style.js' && style) return reply(200, CONTENT_TYPES['.js'], style.js);
334
460
  if (path.startsWith('/lit/three/')) {
@@ -448,9 +574,12 @@ function beatFramesOf(treatment, fps, totalFrames) {
448
574
  });
449
575
  }
450
576
 
451
- function imageUrlsIn(stageDir) {
577
+ // The rasters decoded before frame 0. Declared clip frames are left out: a page decodes each one when it shows it.
578
+ export function imageUrlsIn(stageDir) {
452
579
  const root = realpathSync(stageDir);
453
- return walk(root, root).filter((f) => !f.escape && RASTER.has(extname(f.path).toLowerCase()))
580
+ let prefixes = [];
581
+ try { prefixes = declaredClips(root).map((c) => join(CLIPS_DIR, c.dir) + sep); } catch { /* the scan already refused a bad declaration */ }
582
+ return walk(root, root).filter((f) => !f.escape && RASTER.has(extname(f.path).toLowerCase()) && !prefixes.some((p) => relative(root, f.path).startsWith(p)))
454
583
  .map((f) => `${ORIGIN}/${relative(root, f.path).split(sep).map(encodeURIComponent).join('/')}`);
455
584
  }
456
585
 
@@ -17,7 +17,8 @@ deliverable.
17
17
  Never install, download or copy a third-party library into the stage dir. Use
18
18
  the kit below and your own code. Rasters are textures and stills, never a frame
19
19
  sequence: at most 24 raster images and 8 MB in total; 10 or more rasters of the
20
- same size (a flipbook), or any animated GIF, APNG or WebP, is exit 17.
20
+ same size (a flipbook), or any animated GIF, APNG or WebP, is exit 17. The one
21
+ frame sequence a page may carry is a declared keyed clip (Clips).
21
22
 
22
23
  Put all on-screen copy in one `COPY` object at the top of `stage/index.html`,
23
24
  so the copy is easy to find and edit:
@@ -43,6 +44,8 @@ declares the film:
43
44
  1080×1920 for 9:16. `duration` is the treatment's `durationSec`; the render
44
45
  must land within ±10 % of it.
45
46
  - `fps` is 60. Use 30 only when the treatment says so.
47
+ - A page that shows clips also loads `<script src="/lit/clips.js"></script>`
48
+ after the kit (Clips).
46
49
  - `render(t)` draws the frame at time `t` in seconds. It may be omitted when
47
50
  the page animates only with CSS or the Web Animations API.
48
51
  - Setting `window.litStage` to the same object is equivalent to calling
@@ -168,6 +171,57 @@ scenes, no objects, no layouts, no copy. `window.LitStage` holds:
168
171
  - **Text registration.** `LitStage.text(el, { decor: true })` and
169
172
  `LitStage.text({ content, x, y, w, h })`, above.
170
173
 
174
+ - **Clips.** `LitStage.clip({ parent, id, box })` registers a declared keyed
175
+ clip once and returns `show(t, opts)`, which `render(t)` calls every frame
176
+ (Clips).
177
+
178
+ ```text
179
+ const boom = LitStage.clip({ parent: layer, id: "boom", box: { x: 1200, y: 300, w: 520, h: 511 } });
180
+ boom(t, { start: 2.1 });
181
+ ```
182
+
183
+ ## Clips
184
+
185
+ Real keyed footage (a reaction clip, a spinning loop shot on a green screen) joins the
186
+ film as a layer of the page. The footage is keyed outside the renderer into one 8-bit RGBA
187
+ PNG per frame, the frames are declared, and the page picks the frame from film time. Nothing
188
+ is encoded by hand: the renderer captures the page, so the flash audit, the determinism
189
+ replay and the text checks see the final pixels like any other film.
190
+
191
+ 1. Put each clip's frames in the run's stage folder as `stage/clips/<dir>/frame_0001.png`,
192
+ `frame_0002.png` and on, with no gap and no other image in that folder.
193
+ 2. Declare the clips in `stage/clips/clips.json`:
194
+
195
+ ```json
196
+ { "clips": [ { "id": "boom", "dir": "01-boom", "count": 122, "fps": 30 } ] }
197
+ ```
198
+
199
+ `id` is the name the page uses, `dir` a folder directly under `clips/`, `count` the number
200
+ of frames and `fps` the clip's own rate, 1 to 30. Declare a clip once even if it plays twice.
201
+ 3. The page loads `<script src="/lit/clips.js"></script>` after the kit. For each place a clip
202
+ appears it calls `LitStage.clip({ parent, id, box })` once, outside `render`, and calls the
203
+ returned `show(t, opts)` from `render(t)`. `parent` holds the clip, `box` is its place in
204
+ pixels and the frame is fitted inside it. `show` returns the clip's wrapper element, or
205
+ `null` while the clip is hidden; scale, fade or move that wrapper from `t` like any layer.
206
+
207
+ The frame is `floor((t - start) * fps) + 1`, counted from `from`, so a cue is plain
208
+ arithmetic: `start = cue - (N - 1) / fps` puts frame N on the cue. A clip whose own sound
209
+ sits on frame 85 and has to land on a cue at 0.12 s takes `start = 0.12 - 84 / 30`. Options
210
+ are `start` (film seconds, default 0), `from` and `to` (the frame range, default the whole
211
+ clip), `loop` (wrap by frame index) and `hold` (keep the last frame). Before `start`, and after
212
+ `to` without `loop` or `hold`, the clip is hidden.
213
+
214
+ The pre-flight scan refuses with exit 17, naming the clip: a stage holds at most 12 clips; a
215
+ clip holds at most 360 frames and the stage 1500 in all; more than 40 MB in a clip or 128 MB in
216
+ all; a frame whose long side is above 720 px (a clip is an overlay, not the film); a frame rate
217
+ above 30; frames of mixed sizes; a missing or extra frame; a frame that is not an 8-bit RGBA
218
+ PNG, or has no transparent pixel in the first, middle and last frame (an opaque video is not
219
+ keyed); an animated image; a duplicate `id`; and a `dir` that leaves `clips/`. A folder that is
220
+ not declared, and any frame sequence beside a declared clip, is a flipbook as before. Cover mode takes no clips,
221
+ so a declaration there changes nothing and the folder meets the raster limits.
222
+ The text checks read DOM text, so keep clips clear of copy; a clip laid over a line is yours to
223
+ catch on the stills. The manifest and the gate report list each clip.
224
+
171
225
  ## How the capture works
172
226
 
173
227
  The renderer serves the page to a software-rendered Chrome (SwiftShader, CPU
@@ -485,7 +485,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
485
485
 
486
486
  ```text
487
487
  $ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
488
- Installed LitHermes 1.0.33
488
+ Installed LitHermes 1.0.34
489
489
  plugin: $ISOLATED/plugins/lithermes
490
490
  model config: updated
491
491
 
@@ -502,7 +502,7 @@ installed payload: PASS
502
502
  enabled config: PASS
503
503
 
504
504
  $ hermes lithermes doctor
505
- [OK] plugin.yaml readable (version 1.0.33)
505
+ [OK] plugin.yaml readable (version 1.0.34)
506
506
  [OK] skills bundled: 40
507
507
  [OK] litgoal durable runtime importable
508
508
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/lithermes",
3
- "version": "1.0.33",
3
+ "version": "1.0.34",
4
4
  "description": "Hermes Agent plugin that plans, builds, checks and hands off your work when you add one word, lit, to a request.",
5
5
  "keywords": [
6
6
  "hermes-agent",
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.33"><title>release: 1.0.33</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.33</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.34"><title>release: 1.0.34</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.34</text></g></svg>