@litfamily/lithermes 1.0.27 → 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.
- package/README.md +10 -9
- package/README_Ko-KR.md +10 -9
- package/assets/lithermes-plugin/README.md +28 -9
- package/assets/lithermes-plugin/__init__.py +7 -3
- package/assets/lithermes-plugin/auto_update.py +17 -3
- package/assets/lithermes-plugin/core.py +41 -2
- package/assets/lithermes-plugin/core_contexts.py +27 -9
- package/assets/lithermes-plugin/core_contract.py +7 -2
- package/assets/lithermes-plugin/core_review.py +144 -3
- package/assets/lithermes-plugin/core_routing.py +10 -0
- package/assets/lithermes-plugin/core_workflow_commands.py +3 -2
- package/assets/lithermes-plugin/diagnostics.py +5 -3
- package/assets/lithermes-plugin/lit_reentry.py +233 -0
- package/assets/lithermes-plugin/payload-version.json +180 -48
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/rules/__init__.py +2 -0
- package/assets/lithermes-plugin/rules/engine.py +26 -11
- package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +2 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +3 -3
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/hostexec.mjs +118 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +6 -5
- package/assets/lithermes-plugin/skills/lit-brainstorm/SKILL.md +408 -0
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/doctor.mjs +3 -3
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/export.mjs +5 -5
- package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/hostexec.mjs +118 -0
- package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -4
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/acs/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/elsevier/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/ieee/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/korean-generic/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-docx/templates/latex/nature/template.tex +3 -0
- package/assets/lithermes-plugin/skills/lit-pptx/bin/hostexec.mjs +118 -0
- package/assets/lithermes-plugin/skills/lit-pptx/bin/office.mjs +16 -9
- package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +13 -12
- package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +19 -6
- package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +25 -19
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +2 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/browser.mjs +11 -4
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/constants.mjs +29 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-assets.mjs +87 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-capture.mjs +189 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-gate.mjs +263 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-image.mjs +103 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-init.js +36 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-png.mjs +77 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-sheet.mjs +70 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-treatment.mjs +104 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-webp.mjs +112 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-worker.mjs +19 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +338 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/encode.mjs +1 -1
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/hostexec.mjs +118 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/look.mjs +103 -7
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/loop-kit.js +80 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +1 -1
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +8 -2
- package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +49 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +37 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +49 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +27 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +51 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +51 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +68 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/index.html +112 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/expected.json +96 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/index.html +208 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/expected.json +68 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/cover.md +458 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/craft-loop.md +4 -1
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/runtime.md +13 -5
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +4 -2
- package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +6 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/fonts.json +11 -0
- package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/requirements.lock +72 -0
- package/assets/lithermes-plugin/skills/litgoal/SKILL.md +19 -1
- package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +16 -3
- package/assets/lithermes-plugin/skills/review-work/SKILL.md +38 -16
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
- package/package.json +2 -1
- package/readme-assets/badge-version.svg +1 -1
- package/readme-assets/cover-motion-still.webp +0 -0
- package/readme-assets/cover-motion.webp +0 -0
- package/src/cli.js +2 -2
- package/src/lib/check.js +9 -6
- package/src/lib/hermesDiscovery.js +2 -2
- package/src/lib/hostExec.js +81 -0
- package/src/lib/skillPayload.js +1 -1
- 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 =
|
|
90
|
+
const result = spawnTool(cmd, args, { encoding: 'utf8', ...options });
|
|
90
91
|
if (result.error || result.status !== 0) {
|
|
91
|
-
const detail =
|
|
92
|
-
throw new Error(`${cmd} ${args[0] || ''}
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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'
|
|
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(
|
|
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 =
|
|
149
|
-
if (!py) throw new Error(
|
|
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'
|
|
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(
|
|
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(
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
}
|