@litfamily/lithermes 1.0.26 → 1.0.28

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.
Files changed (96) hide show
  1. package/README.md +10 -9
  2. package/README_Ko-KR.md +10 -9
  3. package/assets/lithermes-plugin/README.md +28 -9
  4. package/assets/lithermes-plugin/__init__.py +8 -3
  5. package/assets/lithermes-plugin/auto_update.py +17 -3
  6. package/assets/lithermes-plugin/core.py +41 -2
  7. package/assets/lithermes-plugin/core_contexts.py +27 -9
  8. package/assets/lithermes-plugin/core_contract.py +7 -2
  9. package/assets/lithermes-plugin/core_review.py +144 -3
  10. package/assets/lithermes-plugin/core_routing.py +10 -0
  11. package/assets/lithermes-plugin/core_workflow_commands.py +3 -2
  12. package/assets/lithermes-plugin/deliverable_hedge_guard.py +171 -48
  13. package/assets/lithermes-plugin/diagnostics.py +5 -3
  14. package/assets/lithermes-plugin/humanizer_detector.py +117 -5
  15. package/assets/lithermes-plugin/lit_reentry.py +233 -0
  16. package/assets/lithermes-plugin/payload-version.json +184 -52
  17. package/assets/lithermes-plugin/plugin.yaml +1 -1
  18. package/assets/lithermes-plugin/rules/__init__.py +2 -0
  19. package/assets/lithermes-plugin/rules/bundled-rules/lit-humanizer.md +2 -0
  20. package/assets/lithermes-plugin/rules/engine.py +26 -11
  21. package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +2 -1
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +3 -3
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/hostexec.mjs +118 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +6 -5
  25. package/assets/lithermes-plugin/skills/lit-brainstorm/SKILL.md +408 -0
  26. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/doctor.mjs +3 -3
  27. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/export.mjs +5 -5
  28. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/hostexec.mjs +118 -0
  29. package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -4
  30. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/acs/template.tex +3 -0
  31. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/elsevier/template.tex +3 -0
  32. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/ieee/template.tex +3 -0
  33. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/korean-generic/template.tex +3 -0
  34. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/nature/template.tex +3 -0
  35. package/assets/lithermes-plugin/skills/lit-humanizer/SKILL.md +2 -0
  36. package/assets/lithermes-plugin/skills/lit-pptx/bin/hostexec.mjs +118 -0
  37. package/assets/lithermes-plugin/skills/lit-pptx/bin/office.mjs +16 -9
  38. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +13 -12
  39. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +19 -6
  40. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +25 -19
  41. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +2 -0
  42. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/browser.mjs +11 -4
  43. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/constants.mjs +29 -0
  44. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-assets.mjs +87 -0
  45. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-capture.mjs +189 -0
  46. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-gate.mjs +263 -0
  47. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-image.mjs +103 -0
  48. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-init.js +36 -0
  49. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-png.mjs +77 -0
  50. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-sheet.mjs +70 -0
  51. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-treatment.mjs +104 -0
  52. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-webp.mjs +112 -0
  53. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-worker.mjs +19 -0
  54. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +338 -0
  55. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  56. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/hostexec.mjs +118 -0
  57. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/look.mjs +103 -7
  58. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/loop-kit.js +80 -0
  59. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  60. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +8 -2
  61. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  62. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +49 -0
  63. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +37 -0
  64. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +49 -0
  65. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +27 -0
  66. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +51 -0
  67. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  68. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +51 -0
  69. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
  70. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +68 -0
  71. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/index.html +112 -0
  72. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/expected.json +96 -0
  73. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/index.html +208 -0
  74. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/expected.json +68 -0
  75. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  76. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/cover.md +458 -0
  77. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/craft-loop.md +4 -1
  78. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/runtime.md +13 -5
  79. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +4 -2
  80. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +6 -0
  81. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/fonts.json +11 -0
  82. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/requirements.lock +72 -0
  83. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +19 -1
  84. package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +16 -3
  85. package/assets/lithermes-plugin/skills/review-work/SKILL.md +38 -16
  86. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  87. package/package.json +2 -1
  88. package/readme-assets/badge-version.svg +1 -1
  89. package/readme-assets/cover-motion-still.webp +0 -0
  90. package/readme-assets/cover-motion.webp +0 -0
  91. package/src/cli.js +2 -2
  92. package/src/lib/check.js +9 -6
  93. package/src/lib/hermesDiscovery.js +2 -2
  94. package/src/lib/hostExec.js +81 -0
  95. package/src/lib/skillPayload.js +1 -1
  96. package/src/lib/updateNotifier.js +14 -4
@@ -12,6 +12,7 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync,
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join, resolve, sep } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { describeSpawnFailure, pickPython, spawnTool, venvPython } from '../engine/hostexec.mjs';
15
16
 
16
17
  export const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
18
  export const runtimeRoot = join(skillRoot, 'runtime');
@@ -86,10 +87,10 @@ export function fontState(env = process.env) {
86
87
  }
87
88
 
88
89
  function run(cmd, args, options = {}) {
89
- const result = spawnSync(cmd, args, { encoding: 'utf8', ...options });
90
+ const result = spawnTool(cmd, args, { encoding: 'utf8', ...options });
90
91
  if (result.error || result.status !== 0) {
91
- const detail = result.error?.message || (result.stderr || '').trim().split('\n').slice(-3).join(' ') || `exit ${result.status}`;
92
- throw new Error(`${cmd} ${args[0] || ''} failed: ${detail}`);
92
+ const detail = (result.stderr || '').trim().split(/\r?\n/).slice(-3).join(' ');
93
+ throw new Error(`${describeSpawnFailure(`${cmd} ${args[0] || ''}`.trim(), { ...result, stderr: detail })}`);
93
94
  }
94
95
  return result.stdout;
95
96
  }
@@ -111,14 +112,9 @@ export async function fetchPinned(entry, targetFile, { mirror = process.env.LITH
111
112
  writeFileSync(targetFile, bytes, { flag: 'wx' });
112
113
  }
113
114
 
114
- function pythonFor(env) {
115
- const candidates = [env.LITHERMES_MOTION_PYTHON, 'python3.11', 'python3.12', 'python3.13', 'python3'].filter(Boolean);
116
- for (const candidate of candidates) {
117
- const probe = spawnSync(candidate, ['-c', 'import sys; print(sys.version.split()[0])'], { encoding: 'utf8' });
118
- if (probe.status === 0) return { python: candidate, version: probe.stdout.trim() };
119
- }
120
- return null;
121
- }
115
+ // The audio venv takes the office runtime's supported-python pick, with a 3.11 floor: the lock pins
116
+ // numpy 2.4 and scipy 1.17, which start at Python 3.11.
117
+ export const pickAudioPython = (env = process.env) => pickPython({ env, override: 'LITHERMES_MOTION_PYTHON', min: [3, 11] });
122
118
 
123
119
  export const audioDir = (cache) => join(cache, `audio-${audioDigest}`);
124
120
 
@@ -126,7 +122,7 @@ export const audioDir = (cache) => join(cache, `audio-${audioDigest}`);
126
122
  export function audioState(env = process.env) {
127
123
  const cache = findCache(env) || cacheRoots(env)[0];
128
124
  const dir = audioDir(cache);
129
- const python = join(dir, 'venv', 'bin', 'python');
125
+ const python = venvPython(join(dir, 'venv'));
130
126
  if (!existsSync(python) || !existsSync(join(dir, 'AUDIO_READY'))) return { state: 'absent', python: null, dir };
131
127
  const expected = [...audioLockBytes.toString().matchAll(/^([A-Za-z0-9_.-]+)==([A-Za-z0-9_.+-]+)/gm)].map(([, name, version]) => [name, version]);
132
128
  const script = [
@@ -139,19 +135,19 @@ export function audioState(env = process.env) {
139
135
  'print(json.dumps(bad))',
140
136
  ].join('\n');
141
137
  const check = spawnSync(python, ['-c', script, JSON.stringify(expected)], { encoding: 'utf8', timeout: 20000 });
142
- if (check.status !== 0) return { state: 'mismatched', python, dir, detail: (check.stderr || '').trim().split('\n').at(-1) };
138
+ if (check.status !== 0) return { state: 'mismatched', python, dir, detail: (check.stderr || '').trim().split(/\r?\n/).at(-1) };
143
139
  const bad = JSON.parse(check.stdout || '[]');
144
140
  return bad.length ? { state: 'mismatched', python, dir, detail: `pins drifted: ${bad.join(', ')}` } : { state: 'ready', python, dir };
145
141
  }
146
142
 
147
143
  async function installAudio(cache, env) {
148
- const py = pythonFor(env);
149
- if (!py) throw new Error('no python3 interpreter found for the --audio venv (set LITHERMES_MOTION_PYTHON)');
144
+ const py = pickAudioPython(env);
145
+ if (!py.python) throw new Error(`${py.detail}; the --audio venv needs one (install Python 3.14 or set LITHERMES_MOTION_PYTHON)`);
150
146
  const dir = audioDir(cache);
151
147
  const stage = `${dir}.stage-${process.pid}`;
152
148
  rmSync(stage, { recursive: true, force: true });
153
149
  run(py.python, ['-m', 'venv', join(stage, 'venv')]);
154
- run(join(stage, 'venv', 'bin', 'python'), ['-m', 'pip', 'install', '--disable-pip-version-check', '--require-hashes', '--only-binary=:all:', '-r', join(runtimeRoot, 'requirements.lock')], { timeout: 900000 });
150
+ run(venvPython(join(stage, 'venv')), ['-m', 'pip', 'install', '--disable-pip-version-check', '--require-hashes', '--only-binary=:all:', '-r', join(runtimeRoot, 'requirements.lock')], { timeout: 900000 });
155
151
  writeFileSync(join(stage, 'AUDIO_READY'), `${audioDigest}\n`);
156
152
  rmSync(dir, { recursive: true, force: true });
157
153
  renameSync(stage, dir);
@@ -217,6 +213,14 @@ export async function install({ audio = false, wordTiming: wantWords = false, en
217
213
  return receipts.join('\n');
218
214
  }
219
215
 
216
+ function coverEncoderLine(env) {
217
+ const tool = (name) => { const probe = spawnSync(name, ['-version'], { encoding: 'utf8', timeout: 10000, env }); return probe.status === 0 ? (/\d+\.\d+(?:\.\d+)?/.exec(`${probe.stdout}${probe.stderr}`)?.[0] || 'unknown version') : null; };
218
+ const img2webp = tool('img2webp'), dwebp = tool('dwebp');
219
+ if (img2webp && dwebp) return `img2webp ${img2webp} and dwebp ${dwebp}; README covers encode as animated WebP and gate the decoded file`;
220
+ if (spawnSync('gifski', ['--version'], { encoding: 'utf8', timeout: 10000, env }).status === 0) return `gifski (GIF fallback; img2webp${img2webp ? '' : ' missing'}, dwebp${dwebp ? '' : ' missing'})`;
221
+ return 'not found: install libwebp (img2webp, dwebp) or gifski; cover renders stop with exit 21';
222
+ }
223
+
220
224
  const chromeProbeScript = () => {
221
225
  const gl = document.createElement('canvas').getContext('webgl2');
222
226
  if (!gl) return null;
@@ -224,7 +228,8 @@ const chromeProbeScript = () => {
224
228
  return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : 'unknown (debug-info extension unavailable)';
225
229
  };
226
230
 
227
- // MO-A-44: five probes on every run, in this product's "name: value" status style.
231
+ // MO-A-44: five probes on every run, in this product's "name: value" status style, plus the
232
+ // cover encoder that cover mode needs (img2webp and dwebp, or the gifski fallback).
228
233
  export async function status(env = process.env) {
229
234
  const { chromeExecutable, launchChrome } = await import('../engine/browser.mjs');
230
235
  const { isSoftwareRenderer, UNKNOWN_RENDERER } = await import('../engine/constants.mjs');
@@ -253,7 +258,7 @@ export async function status(env = process.env) {
253
258
  glLine = `${renderer} (flags: ${launched.flags.join(' ')})`;
254
259
  await launched.context.close().catch(() => {});
255
260
  } catch (error) {
256
- glLine = `no WebGL2 context obtainable: ${String(error.message).split('\n')[0]}`;
261
+ glLine = `no WebGL2 context obtainable: ${String(error.message).split(/\r?\n/)[0]}`;
257
262
  } finally { rmSync(profileRoot, { recursive: true, force: true }); }
258
263
  }
259
264
  const software = !renderer ? 'unknown (no renderer probed)'
@@ -269,10 +274,11 @@ export async function status(env = process.env) {
269
274
  const warm = warmParts.length ? `NOT READY - ${warmParts.join('; ')}; fix: ${WARM}` : `READY cache=${deps.cache}`;
270
275
  return [
271
276
  `Chrome: ${chrome ? `${chrome} (${version || 'version unknown'})` : 'not found on PATH / not installed (set CHROME_PATH)'}`,
272
- `ffmpeg: ${ffmpeg.status === 0 ? `${ffmpeg.stdout.split('\n')[0]}; preview encoder: ${preview}` : 'not found on PATH (video export blocked; stills and sheet still work)'}`,
277
+ `ffmpeg: ${ffmpeg.status === 0 ? `${ffmpeg.stdout.split(/\r?\n/)[0]}; preview encoder: ${preview}` : 'not found on PATH (video export blocked; stills and sheet still work)'}`,
273
278
  `WebGL2 renderer: ${glLine}`,
274
279
  `software GL: ${software}`,
275
280
  `pre-warm: ${warm}; ${audioText}`,
281
+ `cover encoder: ${coverEncoderLine(env)}`,
276
282
  ];
277
283
  }
278
284
 
@@ -49,6 +49,8 @@ font: <cache>/assets/fonts/stroke/EMSReadability.svg
49
49
  licence: runtime/licenses/EMS-OFL.txt and runtime/licenses/CREDITS (in this package)
50
50
  font: <cache>/assets/fonts/stroke/EMSTech.svg
51
51
  licence: runtime/licenses/EMS-OFL.txt and runtime/licenses/CREDITS (in this package)
52
+ font: <cache>/assets/fonts/JetBrainsMono-wght-latin.woff2 (variable wght 100-800, latin subset, from @fontsource-variable/jetbrains-mono 5.3.0; unmodified)
53
+ licence: <cache>/assets/licenses/JetBrainsMono-OFL.txt (SIL OFL 1.1, no Reserved Font Name)
52
54
  font: ../lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf (reused by path, unmodified)
53
55
  licence: ../lit-pptx/fonts/PRETENDARD-OFL.txt (SIL OFL 1.1)
54
56
  font: ../lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf (reused by path, unmodified)
@@ -5,7 +5,7 @@
5
5
  // pull of the same readback buffer (the sanctioned variant). Each MO-A-51 rung
6
6
  // counts only after a real getContext('webgl2') in the page.
7
7
  import { existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync } from 'node:fs';
8
- import { join } from 'node:path';
8
+ import { join, win32 } from 'node:path';
9
9
  import { EXIT, UNKNOWN_RENDERER, chromeFlagLadder, isSoftwareRenderer } from './constants.mjs';
10
10
 
11
11
  export const blocked = (code, message) => Object.assign(new Error(message), { exitCode: code });
@@ -16,9 +16,16 @@ const CHROME_CANDIDATES = [
16
16
  '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser',
17
17
  ];
18
18
 
19
- export function chromeExecutable(env = process.env) {
20
- if (env.CHROME_PATH) return existsSync(env.CHROME_PATH) ? env.CHROME_PATH : null;
21
- return CHROME_CANDIDATES.find((candidate) => existsSync(candidate)) || null;
19
+ const WINDOWS_CHROME = ['ProgramFiles', 'ProgramFiles(x86)', 'LOCALAPPDATA']
20
+ .flatMap((key) => [['Google', 'Chrome', 'Application', 'chrome.exe'], ['Chromium', 'Application', 'chrome.exe']].map((tail) => [key, tail]));
21
+
22
+ export function chromeExecutable(env = process.env, { platform = process.platform, exists = existsSync } = {}) {
23
+ if (env.CHROME_PATH) return exists(env.CHROME_PATH) ? env.CHROME_PATH : null;
24
+ if (platform === 'win32') {
25
+ const found = WINDOWS_CHROME.map(([key, tail]) => (env[key] ? win32.join(env[key], ...tail) : null)).find((file) => file && exists(file));
26
+ return found || null;
27
+ }
28
+ return CHROME_CANDIDATES.find((candidate) => exists(candidate)) || null;
22
29
  }
23
30
 
24
31
  // The launcher's first line that names a cause. Crashpad's own complaints are
@@ -143,6 +143,35 @@ export const EXIT = Object.freeze({
143
143
  STAGE_NONDETERMINISTIC: 18,
144
144
  STAGE_NETWORK_REQUEST: 19,
145
145
  SOUND_INVALID: 20,
146
+ // Cover mode.
147
+ BLOCKED_NO_COVER_ENCODER: 21,
148
+ BLOCKED_ASSET_MISSING: 22,
149
+ });
150
+
151
+ // Cover mode (references/cover.md): the decoded-file gate, calibrated on 2026-10-20 against eight
152
+ // earlier painted README covers, four slide-like negative loops and three pilot loops. FLOOR fails
153
+ // a cover (every calibration cover passes it, every negative fails it). TARGET is what a cover
154
+ // render must reach; the calibration covers miss it on purpose (a 1.9 s hold, a flash at the seam).
155
+ // A number here changes only with a new calibration table, never to let a cover pass.
156
+ export const COVER = Object.freeze({
157
+ maxBytes: 3 * 1024 * 1024, targetBytes: 2 * 1024 * 1024,
158
+ minSec: 6, maxSec: 12, fps: Object.freeze([16, 20, 25]),
159
+ gridMs: 50, // metrics run on a grid of max(gridMs, the shortest decoded frame)
160
+ stillEps: 0.0005, // a grid step whose mean |dY| (0..1, ~300 px wide) is below this is still
161
+ floor: Object.freeze({ medianD: 0.0004, movingFrac: 0.45, stillSec: 2.0, seamLocal: 8 }),
162
+ target: Object.freeze({ medianD: 0.0015, movingFrac: 0.95, stillSec: 0.5, spike: 6, seamLo: 0.25, seamHi: 2.0 }),
163
+ minContrast: 4.5,
164
+ minCapPx: 24, displayWidth: 830, // G6: cap height at the README column width
165
+ artSsim: 0.9, artMinShare: 0.1, // frame 0 shows the base art
166
+ seekSamples: 16, seekLevels: 1, // seek-order check: frames re-drawn out of order, one-level tolerance
167
+ windowPadSec: 0.5, maxDefects: 3,
168
+ captureScale: 2,
169
+ // Laid out at `page` CSS px, captured at captureScale, downsampled (Lanczos) to `size`.
170
+ aspects: Object.freeze({
171
+ '2:1': Object.freeze({ page: Object.freeze([1200, 600]), size: Object.freeze([1000, 500]) }),
172
+ '3:2': Object.freeze({ page: Object.freeze([1200, 800]), size: Object.freeze([960, 640]) }),
173
+ }),
174
+ encode: Object.freeze({ q: 86, edgeQ: 97, edgeFrames: 15, method: 4 }),
146
175
  });
147
176
 
148
177
  // MO-A-58 post-chain override vocabulary: [min, max, neutral]. null max = no ceiling.
@@ -0,0 +1,87 @@
1
+ // Cover assets: every raster a cover page can load (base art, logo, wordmark, texture) is listed in
2
+ // <out>/assets.json with its path, role, source (user or generated) and SHA-256, and the renderer
3
+ // checks the list before the first capture. A listed file that is missing is BLOCKED_ASSET_MISSING
4
+ // (exit 22): the art is supplied or generated, never replaced by a drawn stand-in. The
5
+ // painted-light recipe needs its base art listed. After the render, frame 0 has to show that art:
6
+ // the SSIM of its check box in decoded frame 0 against the source scaled into the art's box.
7
+ import { createHash } from 'node:crypto';
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join, sep } from 'node:path';
10
+ import { COVER, EXIT } from './constants.mjs';
11
+ import { crop, lanczosResize, ssim } from './cover-image.mjs';
12
+
13
+ export const ASSETS_FILE = 'assets.json';
14
+ export const ASSET_ROLES = Object.freeze(['base-art', 'logo', 'wordmark', 'texture']);
15
+
16
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
17
+ const exit = (code, message) => Object.assign(new Error(message), { exitCode: code });
18
+ const invalid = (field, message) => exit(EXIT.BLOCKED_TREATMENT_INVALID, `BLOCKED_TREATMENT_INVALID: field assets.json ${field}: ${message}`);
19
+ const inside = (b) => Array.isArray(b) && b.length === 4 && b.every((v) => typeof v === 'number' && v >= 0 && v <= 1)
20
+ && b[2] > 0 && b[3] > 0 && b[0] + b[2] <= 1 + 1e-9 && b[1] + b[3] <= 1 + 1e-9;
21
+
22
+ function entry(a, i, coverDir) {
23
+ const at = `assets[${i}]`;
24
+ if (!a || typeof a !== 'object') throw invalid(at, 'must be an object');
25
+ if (typeof a.path !== 'string' || !a.path.startsWith(`${coverDir}/`) || a.path.split('/').includes('..')) throw invalid(`${at}.path`, `must be a path under ${coverDir}/, relative to the output directory`);
26
+ if (!ASSET_ROLES.includes(a.role)) throw invalid(`${at}.role`, `must be one of ${ASSET_ROLES.join(', ')}`);
27
+ if (!['user', 'generated'].includes(a.source)) throw invalid(`${at}.source`, 'must be user (supplied) or generated (made for this cover)');
28
+ if (!/^[0-9a-f]{64}$/u.test(a.sha256 ?? '')) throw invalid(`${at}.sha256`, 'must be the file\'s SHA-256 (64 hex)');
29
+ if (a.box !== undefined && !inside(a.box)) throw invalid(`${at}.box`, 'must be [x, y, w, h], fractions of the cover, inside it');
30
+ if (a.role === 'base-art' && !a.box) throw invalid(`${at}.box`, 'base art needs the box it fills on the cover');
31
+ if (a.checkBox !== undefined) {
32
+ if (!inside(a.checkBox) || !a.box) throw invalid(`${at}.checkBox`, 'must be [x, y, w, h] inside the cover, beside a box');
33
+ const [bx, by, bw, bh] = a.box, [cx, cy, cw, ch] = a.checkBox;
34
+ if (cx < bx - 1e-9 || cy < by - 1e-9 || cx + cw > bx + bw + 1e-9 || cy + ch > by + bh + 1e-9) throw invalid(`${at}.checkBox`, 'must lie inside the art\'s box');
35
+ if (cw * ch < COVER.artMinShare * bw * bh) throw invalid(`${at}.checkBox`, `must cover at least ${COVER.artMinShare * 100} % of the art's box`);
36
+ }
37
+ return { path: a.path, role: a.role, source: a.source, sha256: a.sha256, box: a.box || null, checkBox: a.checkBox || null, title: a.title === true };
38
+ }
39
+
40
+ // rasters: the static scan's raster paths, relative to the cover folder. Exit 22 for a missing
41
+ // listed file or a painted recipe without base art; exit 17 for an unlisted raster or a changed
42
+ // file; exit 16 for a malformed entry.
43
+ export function checkAssets({ out, recipe, rasters, coverDir = 'cover' }) {
44
+ const file = join(out, ASSETS_FILE);
45
+ let assets = [];
46
+ if (existsSync(file)) {
47
+ let doc;
48
+ try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw invalid('', `is not valid JSON: ${error.message}`); }
49
+ if (!doc || !Array.isArray(doc.assets)) throw invalid('assets', 'must be an array of { path, role, source, sha256 }');
50
+ assets = doc.assets.map((a, i) => entry(a, i, coverDir));
51
+ for (const a of assets) {
52
+ const real = join(out, a.path);
53
+ if (!existsSync(real)) throw exit(EXIT.BLOCKED_ASSET_MISSING, `BLOCKED_ASSET_MISSING: ${a.path} is listed in assets.json and missing; supply it or generate it, never draw a stand-in`);
54
+ if (sha(readFileSync(real)) !== a.sha256) throw exit(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${a.path} no longer matches its sha256 in assets.json; list it again after changing it`);
55
+ }
56
+ }
57
+ if (recipe === 'painted-light' && !assets.some((a) => a.role === 'base-art')) {
58
+ throw exit(EXIT.BLOCKED_ASSET_MISSING, 'BLOCKED_ASSET_MISSING: the painted-light recipe needs its base art listed in assets.json ({ path, role: base-art, source, sha256, box }); supply or generate the painting, never draw a stand-in');
59
+ }
60
+ const listed = new Set(assets.map((a) => a.path));
61
+ for (const rel of rasters) {
62
+ const path = `${coverDir}/${rel.split(sep).join('/')}`;
63
+ if (!listed.has(path)) throw exit(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${path} is a raster the page can load but assets.json does not list; list it with its role, source and sha256`);
64
+ }
65
+ return assets;
66
+ }
67
+
68
+ const toPx = (box, W, H) => box.map((v, k) => Math.round(v * (k % 2 ? H : W)));
69
+
70
+ // Frame 0 shows the base art: the check box (default: the whole art box) cut from decoded frame 0,
71
+ // against the same region of the source scaled into the art box. Boxes are cover fractions.
72
+ export function artCheck({ frame0, width, height, art, box, checkBox = null }) {
73
+ const [bx, by, bw, bh] = toPx(box, width, height);
74
+ const scaled = lanczosResize(art.rgba, art.width, art.height, bw, bh);
75
+ const [cx, cy, cw, ch] = toPx(checkBox || box, width, height);
76
+ const value = ssim(crop(frame0, width, [cx, cy, cw, ch]), crop(scaled, bw, [cx - bx, cy - by, cw, ch]), cw, ch);
77
+ return { ssim: value, pass: value >= COVER.artSsim, checked: [cx, cy, cw, ch] };
78
+ }
79
+
80
+ // The labels the reply carries for every generated asset (complete prints them).
81
+ export function generatedLabels(out) {
82
+ try {
83
+ const doc = JSON.parse(readFileSync(join(out, ASSETS_FILE), 'utf8'));
84
+ return (doc.assets || []).filter((a) => a?.source === 'generated' && typeof a.path === 'string')
85
+ .map((a) => `the ${String(a.role || 'asset').replace('-', ' ')} ${a.path} was generated for this cover, not supplied: label it as generated in the reply`);
86
+ } catch { return []; }
87
+ }
@@ -0,0 +1,189 @@
1
+ // Cover capture. One headless Chrome per pass, on the stage path's software rung (so two captures
2
+ // can only differ because of the page), laid out at the cover's page size in CSS px and captured
3
+ // at device scale 2. The page is served from the synthetic stage origin through CDP request
4
+ // interception (no socket listens), with the loop kit at /lit/loop-kit.js and the faces at
5
+ // /lit/fonts.css; the cover init script runs before any page script. The renderer SEEKS: it calls
6
+ // window.renderFrame(t, { mask }) for any t in [0, 1), in any order, and a worker pool brings each
7
+ // capture down to the cover size with Lanczos and writes it as a PNG. The master, the replay and
8
+ // the seek-order pass each open their own browser.
9
+ import { mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
10
+ import { cpus } from 'node:os';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { Worker } from 'node:worker_threads';
14
+ import { EXIT } from './constants.mjs';
15
+ import { chromeExecutable, launchCause } from './browser.mjs';
16
+ import { CONTENT_TYPES, ORIGIN, stageFile, stageFlags } from './stage.mjs';
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const fail = (code, message) => Object.assign(new Error(message), { exitCode: code });
20
+ const within = (promise, ms, message) => Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms).unref())]);
21
+
22
+ export const coverFlags = ({ width, height, scale }) => stageFlags({ width, height })
23
+ .map((flag) => (flag === '--force-device-scale-factor=1' ? `--force-device-scale-factor=${scale}` : flag));
24
+
25
+ // The init script as Chrome runs it, plus a paint wait that keeps the native frame callback the
26
+ // init script takes away from the page.
27
+ function initSource() {
28
+ const init = readFileSync(join(here, 'cover-init.js'), 'utf8').replace(/^\/\/.*\n/gmu, '').trim();
29
+ const paint = 'const nativeRAF = window.requestAnimationFrame.bind(window); window.__litCoverPaint = () => new Promise((done) => nativeRAF(() => nativeRAF(done)));';
30
+ return `(() => { ${paint} })(); try { ${init}(); } catch (error) { window.__litCoverInitError = String(error && error.stack || error); }`;
31
+ }
32
+
33
+ export async function openCover({ runtime, coverDir, fonts, width, height, scale, profileRoot, env = process.env }) {
34
+ const chrome = chromeExecutable(env);
35
+ if (!chrome) throw fail(EXIT.BLOCKED_NO_CHROME, 'BLOCKED_NO_CHROME: Chrome/Chromium not found; install Chrome or set CHROME_PATH');
36
+ const { chromium } = runtime('playwright-core');
37
+ mkdirSync(profileRoot, { recursive: true });
38
+ const profile = join(profileRoot, 'p');
39
+ const flags = coverFlags({ width, height, scale });
40
+ let context;
41
+ try {
42
+ context = await chromium.launchPersistentContext(profile, {
43
+ executablePath: chrome, headless: true, chromiumSandbox: true, args: flags, timeout: 30000,
44
+ viewport: { width, height }, deviceScaleFactor: scale, serviceWorkers: 'block',
45
+ });
46
+ } catch (error) {
47
+ rmSync(profile, { recursive: true, force: true });
48
+ throw fail(EXIT.BLOCKED_NO_CHROME, `BLOCKED_NO_CHROME: headless Chrome failed to launch on the software rung: ${launchCause(error)}`);
49
+ }
50
+ const page = context.pages()[0] || await context.newPage();
51
+ page.setDefaultTimeout(0);
52
+ const cdp = await context.newCDPSession(page);
53
+ const kit = readFileSync(join(here, 'loop-kit.js'));
54
+ const root = realpathSync(coverDir);
55
+ const refused = [], contract = [], errors = [];
56
+ page.on('pageerror', (error) => errors.push(String(error.message || error).split('\n')[0]));
57
+ cdp.on('Fetch.requestPaused', async ({ requestId, request }) => {
58
+ let url = null;
59
+ try { url = new URL(request.url); } catch { /* an unparsable URL is refused below */ }
60
+ if (!url || url.origin !== ORIGIN) {
61
+ refused.push(request.url.slice(0, 160));
62
+ return cdp.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }).catch(() => {});
63
+ }
64
+ const reply = (status, type, body) => cdp.send('Fetch.fulfillRequest', {
65
+ requestId, responseCode: status, responseHeaders: [{ name: 'Content-Type', value: type }, { name: 'Cache-Control', value: 'no-store' }],
66
+ body: Buffer.from(body).toString('base64'),
67
+ }).catch(() => {});
68
+ const path = decodeURIComponent(url.pathname);
69
+ if (path === '/lit/loop-kit.js') return reply(200, CONTENT_TYPES['.js'], kit);
70
+ if (path === '/lit/fonts.css') return reply(200, CONTENT_TYPES['.css'], fonts.css);
71
+ if (fonts.routes.has(path)) return reply(200, CONTENT_TYPES[`.${path.split('.').pop()}`] || 'font/ttf', readFileSync(fonts.routes.get(path)));
72
+ const found = stageFile(root, path);
73
+ if (found.status === 403) { contract.push(found.reason); return reply(403, 'text/plain', 'forbidden'); }
74
+ if (found.status === 404) return reply(404, 'text/plain', 'not found');
75
+ return reply(200, found.type, readFileSync(found.file));
76
+ });
77
+ await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
78
+ await page.addInitScript({ content: initSource() });
79
+
80
+ const evaluate = (fn, arg, what) => within(page.evaluate(fn, arg), 120000, `the cover page did not answer within 120 s (${what})`);
81
+ const browser = {
82
+ flags, errors,
83
+ // Throw the named exit for anything a cover page may not do.
84
+ async check(where) {
85
+ if (refused.length) throw fail(EXIT.STAGE_NETWORK_REQUEST, `STAGE_NETWORK_REQUEST: the cover page requested ${refused[0]}${refused.length > 1 ? ` (+${refused.length - 1} more)` : ''} ${where}; everything is local`);
86
+ if (contract.length) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${contract[0]}`);
87
+ const misuse = await evaluate(() => (window.__litCoverMisuse ? window.__litCoverMisuse() : []), null, 'misuse check').catch(() => []);
88
+ if (misuse.length) throw fail(EXIT.STAGE_NONDETERMINISTIC, `STAGE_NONDETERMINISTIC: the cover page called ${misuse.join(', ')} ${where}; a cover frame is a function of t alone (LitLoop.rng(seed) for layout)`);
89
+ },
90
+ async load() {
91
+ await page.goto(`${ORIGIN}/index.html`, { waitUntil: 'load', timeout: 120000 });
92
+ const deadline = Date.now() + 60000;
93
+ for (;;) {
94
+ const state = await evaluate(() => ({ ready: window.ready === true, render: typeof window.renderFrame, init: window.__litCoverInitError || null }), null, 'ready');
95
+ if (state.init) throw fail(1, `cover init failed: ${state.init}`);
96
+ // A page that threw before it was ready (for example ART.lights has no entry for the hero
97
+ // bay N) never becomes ready; say so now instead of after the 60 s wait.
98
+ if (!state.ready && errors.length) {
99
+ await browser.check('while loading');
100
+ throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: the cover page threw before it set window.ready, so it stopped at once instead of waiting 60 s; first page error: ${errors[0]}`);
101
+ }
102
+ if (state.ready) {
103
+ await browser.check('while loading');
104
+ if (state.render !== 'function') throw fail(EXIT.STAGE_CONTRACT_ERROR, 'STAGE_CONTRACT_ERROR: the cover page set window.ready but defines no window.renderFrame(t)');
105
+ return;
106
+ }
107
+ if (Date.now() > deadline) {
108
+ await browser.check('while loading');
109
+ throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: the cover page never set window.ready = true within 60 s${errors.length ? `; first page error: ${errors[0]}` : ''}`);
110
+ }
111
+ await new Promise((r) => setTimeout(r, 50));
112
+ }
113
+ },
114
+ // Draw loop time t (or its title mask) and wait until it is painted.
115
+ async frame(t, { mask = false } = {}) {
116
+ const at = `at t = ${t.toFixed(4)}`;
117
+ try {
118
+ await evaluate(async ([time, m]) => {
119
+ await window.renderFrame(time, { mask: m });
120
+ if (document.fonts) await document.fonts.ready;
121
+ await window.__litCoverPaint();
122
+ }, [t, mask], at);
123
+ } catch (error) {
124
+ await browser.check(at);
125
+ throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: renderFrame(${t.toFixed(4)}${mask ? ', { mask: true }' : ''}) threw: ${String(error.message).split('\n')[0]}`);
126
+ }
127
+ await browser.check(at);
128
+ },
129
+ async capture() {
130
+ const shot = await cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true, captureBeyondViewport: false, fromSurface: true, clip: { x: 0, y: 0, width, height, scale: 1 } });
131
+ return Buffer.from(shot.data, 'base64');
132
+ },
133
+ async close() {
134
+ await context.close().catch(() => {});
135
+ rmSync(profile, { recursive: true, force: true });
136
+ },
137
+ };
138
+ try { await browser.load(); } catch (error) { await browser.close(); throw error; }
139
+ return browser;
140
+ }
141
+
142
+ // Workers that downsample a 2x capture to the cover size and write it as an RGB PNG.
143
+ export class CoverPool {
144
+ constructor(size = Math.max(2, Math.min(4, cpus().length - 2))) {
145
+ this.workers = Array.from({ length: size }, () => new Worker(new URL('./cover-worker.mjs', import.meta.url)));
146
+ this.turn = 0;
147
+ this.id = 0;
148
+ this.waiting = new Map();
149
+ for (const worker of this.workers) {
150
+ worker.on('message', (msg) => {
151
+ const job = this.waiting.get(msg.id);
152
+ this.waiting.delete(msg.id);
153
+ if (msg.error) job.reject(new Error(msg.error)); else job.resolve(msg);
154
+ });
155
+ worker.on('error', (error) => { for (const job of this.waiting.values()) job.reject(error); this.waiting.clear(); });
156
+ }
157
+ }
158
+
159
+ run(png, width, height, file) {
160
+ const id = ++this.id, worker = this.workers[this.turn++ % this.workers.length];
161
+ const copy = png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength);
162
+ return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); worker.postMessage({ id, png: copy, width, height, file }, [copy]); });
163
+ }
164
+
165
+ async close() { await Promise.all(this.workers.map((w) => w.terminate())); }
166
+ }
167
+
168
+ export const frameName = (prefix, index) => `${prefix}${String(index).padStart(4, '0')}.png`;
169
+
170
+ // Capture [index, t] pairs in the given order into dir as <prefix><index>.png at the cover size;
171
+ // returns Map index -> SHA-256 of the written PNG.
172
+ export async function capturePass({ browser, pool, times, dir, prefix = 'f', mask = false, size, page, scale }) {
173
+ mkdirSync(dir, { recursive: true });
174
+ const hashes = new Map(), jobs = [];
175
+ const expected = `${page[0] * scale}x${page[1] * scale}`;
176
+ for (const [index, t] of times) {
177
+ await browser.frame(t, { mask });
178
+ const png = await browser.capture();
179
+ const job = pool.run(png, size[0], size[1], join(dir, frameName(prefix, index))).then((r) => {
180
+ if (r.captured !== expected) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: a capture came back ${r.captured}, not ${expected}; the page must not resize its viewport`);
181
+ hashes.set(index, r.sha256);
182
+ });
183
+ job.catch(() => {}); // awaited below; this only keeps an early failure from going unhandled
184
+ jobs.push(job);
185
+ if (jobs.length > 8) await jobs.shift();
186
+ }
187
+ await Promise.all(jobs);
188
+ return hashes;
189
+ }