@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The cover treatment: treatment.json with "mode": "cover" (references/cover.md). A cover is not
|
|
2
|
+
// a film. It has no beats, no arc, no end card, no sound and no copy lines; it names a recipe, one
|
|
3
|
+
// rule copied many times, three speed layers, the brand word set once (plus at most one motto the
|
|
4
|
+
// user or the project already uses), the aspect and loop length, and the title box the contrast
|
|
5
|
+
// and cap-height gates measure. Every cover render validates it and stops with exit 16 naming the
|
|
6
|
+
// first bad field. Its text is the user's content, never instructions.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { COVER, EXIT } from './constants.mjs';
|
|
10
|
+
import { normalizeText, sharesSubstring } from './treatment.mjs';
|
|
11
|
+
|
|
12
|
+
export const RECIPES = Object.freeze(['painted-light', 'weight-tide', 'orbit-loop', 'own']);
|
|
13
|
+
const PLACEHOLDER = /<[^<>\n]{1,80}>|^\s*(?:todo|tbd|xxx|\.\.\.|…)\s*$/iu;
|
|
14
|
+
|
|
15
|
+
class Invalid extends Error {
|
|
16
|
+
constructor(field, message) { super(message); this.field = field; }
|
|
17
|
+
}
|
|
18
|
+
const need = (cond, field, message) => { if (!cond) throw new Invalid(field, message); };
|
|
19
|
+
const sentences = (value) => String(value).trim().split(/(?<=[.!?。])\s+/u).filter((s) => s.trim()).length;
|
|
20
|
+
const words = (value) => String(value).trim().split(/\s+/u).filter(Boolean).length;
|
|
21
|
+
const isBox = (b) => Array.isArray(b) && b.length === 4 && b.every((v) => typeof v === 'number' && v >= 0 && v <= 1)
|
|
22
|
+
&& b[2] > 0 && b[3] > 0 && b[0] + b[2] <= 1 + 1e-9 && b[1] + b[3] <= 1 + 1e-9;
|
|
23
|
+
|
|
24
|
+
function prose(t, path, { max = 2 } = {}) {
|
|
25
|
+
const value = path.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), t);
|
|
26
|
+
need(typeof value === 'string' && value.trim(), path, `${path} is required (a non-empty string)`);
|
|
27
|
+
need(!PLACEHOLDER.test(value), path, `${path} is a placeholder (${JSON.stringify(value.slice(0, 40))})`);
|
|
28
|
+
need(sentences(value) <= max, path, `${path} takes at most ${max} sentence(s)`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function check(t) {
|
|
33
|
+
need(t && typeof t === 'object' && !Array.isArray(t), 'treatment', 'treatment.json must be a JSON object');
|
|
34
|
+
need(t.mode === 'cover', 'mode', 'a cover render needs "mode": "cover"');
|
|
35
|
+
prose(t, 'request', { max: 20 });
|
|
36
|
+
prose(t, 'idea', { max: 1 });
|
|
37
|
+
need(!sharesSubstring(t.idea, t.request), 'idea', 'idea restates the request; say what the loop does');
|
|
38
|
+
need(RECIPES.includes(t.recipe), 'recipe', `recipe must be one of ${RECIPES.join(', ')}`);
|
|
39
|
+
prose(t, 'recipeReason');
|
|
40
|
+
prose(t, 'brandWord', { max: 1 });
|
|
41
|
+
need(words(t.brandWord) <= 3, 'brandWord', 'brandWord is the name set once: at most three words');
|
|
42
|
+
if (t.motto !== undefined) {
|
|
43
|
+
need(t.motto && typeof t.motto === 'object', 'motto', 'motto must be { text, source }');
|
|
44
|
+
prose(t, 'motto.text', { max: 1 });
|
|
45
|
+
need(words(t.motto.text) <= 6, 'motto.text', 'motto.text takes at most six words');
|
|
46
|
+
need(['user', 'project'].includes(t.motto.source), 'motto.source', 'motto.source is user (it appears in the request) or project (with from)');
|
|
47
|
+
if (t.motto.source === 'user') need(normalizeText(t.request).includes(normalizeText(t.motto.text)), 'motto.text', 'a user motto appears in the request; a cover never invents a tagline');
|
|
48
|
+
else prose(t, 'motto.from');
|
|
49
|
+
}
|
|
50
|
+
for (const key of ['copy', 'beats', 'lines']) need(t[key] === undefined, key, `${key}: a cover has no copy lines and no beats; the brand word is set once and never swapped`);
|
|
51
|
+
need(t.sound === undefined || t.sound === 'none' || t.sound?.mode === 'none', 'sound', 'a cover is silent: leave sound out or set it to none');
|
|
52
|
+
need(Object.hasOwn(COVER.aspects, t.aspect), 'aspect', `aspect must be ${Object.keys(COVER.aspects).join(' or ')}`);
|
|
53
|
+
need(typeof t.loopSec === 'number' && t.loopSec >= COVER.minSec && t.loopSec <= COVER.maxSec, 'loopSec', `loopSec must be ${COVER.minSec}-${COVER.maxSec} seconds`);
|
|
54
|
+
need(COVER.fps.includes(t.fps), 'fps', `fps must be one of ${COVER.fps.join(', ')}`);
|
|
55
|
+
need(t.layers && typeof t.layers === 'object', 'layers', 'layers names what moves on the slow, mid and beat layers');
|
|
56
|
+
for (const k of ['slow', 'mid', 'beat']) prose(t, `layers.${k}`);
|
|
57
|
+
need(t.system && typeof t.system === 'object', 'system', 'system names the one rule, its copies and where their offsets come from');
|
|
58
|
+
for (const k of ['rule', 'copies', 'offset']) prose(t, `system.${k}`);
|
|
59
|
+
need(t.title && typeof t.title === 'object', 'title', 'title is { box: [x, y, w, h] in cover fractions, kind: live or painted }');
|
|
60
|
+
need(isBox(t.title.box), 'title.box', 'title.box is [x, y, w, h], fractions of the cover, inside it');
|
|
61
|
+
need(['live', 'painted'].includes(t.title.kind), 'title.kind', 'title.kind is live (set as text) or painted (part of the base art)');
|
|
62
|
+
if (t.palette !== undefined) {
|
|
63
|
+
need(Array.isArray(t.palette) && t.palette.length >= 2 && t.palette.length <= 6, 'palette', 'palette takes 2-6 { color, role } entries');
|
|
64
|
+
t.palette.forEach((p, i) => {
|
|
65
|
+
need(/^#[0-9a-f]{6}$/iu.test(p?.color ?? ''), `palette[${i}].color`, 'a colour is #rrggbb');
|
|
66
|
+
need(typeof p?.role === 'string' && p.role.trim(), `palette[${i}].role`, 'say the colour\'s job');
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (t.encode !== undefined) {
|
|
70
|
+
need(t.encode && typeof t.encode === 'object', 'encode', 'encode is { q, edgeQ, edgeFrames }');
|
|
71
|
+
for (const [k, lo, hi] of [['q', 50, 100], ['edgeQ', 50, 100], ['edgeFrames', 0, 60]]) {
|
|
72
|
+
if (t.encode[k] !== undefined) need(Number.isInteger(t.encode[k]) && t.encode[k] >= lo && t.encode[k] <= hi, `encode.${k}`, `encode.${k} is an integer ${lo}-${hi}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (t.seed !== undefined) need(Number.isInteger(t.seed) && t.seed >= 0, 'seed', 'seed is a non-negative integer');
|
|
76
|
+
prose(t, 'ambition');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// { ok, field, message }; a valid treatment also returns the derived render plan.
|
|
80
|
+
export function validateCoverTreatment(t) {
|
|
81
|
+
try {
|
|
82
|
+
check(t);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof Invalid) return { ok: false, field: error.field, message: error.message };
|
|
85
|
+
return { ok: false, field: 'treatment', message: `treatment could not be read: ${error.message}` };
|
|
86
|
+
}
|
|
87
|
+
const { page, size } = COVER.aspects[t.aspect];
|
|
88
|
+
return { ok: true, plan: { page, size, frames: Math.round(t.loopSec * t.fps), encode: { ...COVER.encode, ...(t.encode || {}) }, seed: t.seed ?? 20261020 } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const isCoverTreatment = (t) => Boolean(t && typeof t === 'object' && t.mode === 'cover');
|
|
92
|
+
|
|
93
|
+
// Read and validate <out>/treatment.json as a cover; a failure is exit 16 naming the field.
|
|
94
|
+
export function loadCoverTreatment(out) {
|
|
95
|
+
const file = join(out, 'treatment.json');
|
|
96
|
+
const blocked = (field, message) => Object.assign(new Error(`BLOCKED_TREATMENT_INVALID: field ${field}: ${message}`), { exitCode: EXIT.BLOCKED_TREATMENT_INVALID, field });
|
|
97
|
+
if (!existsSync(file)) throw blocked('treatment', `no treatment.json in ${out}; write the cover treatment first (references/cover.md)`);
|
|
98
|
+
let t;
|
|
99
|
+
try { t = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw blocked('treatment', `treatment.json is not valid JSON: ${error.message}`); }
|
|
100
|
+
if (!isCoverTreatment(t)) throw blocked('mode', 'this treatment is a film; render it with `stage` or `run` (a cover treatment sets "mode": "cover")');
|
|
101
|
+
const result = validateCoverTreatment(t);
|
|
102
|
+
if (!result.ok) throw blocked(result.field, result.message);
|
|
103
|
+
return { treatment: t, file, ...result.plan };
|
|
104
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Decode an animated WebP as a browser would show it, with no npm dependency: the RIFF container is
|
|
2
|
+
// read here (canvas size, loop count, each ANMF frame's rectangle, duration, blend and dispose
|
|
3
|
+
// bits), each frame's own bitstream is rewrapped as a still and handed to the host's dwebp (it
|
|
4
|
+
// ships with img2webp in libwebp), and the stills are composited the way libwebp's animation
|
|
5
|
+
// decoder does. The cover gate reads the encoded file through this module: a lossy animation
|
|
6
|
+
// encoder skips small changes, and only the decoded file shows what it skipped.
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { whichSync } from './hostexec.mjs';
|
|
12
|
+
|
|
13
|
+
// A PATH walk with PATHEXT on Windows; `where` prints CRLF lines and extensionless shims.
|
|
14
|
+
export function which(name, env = process.env, probes = {}) {
|
|
15
|
+
return whichSync(name, { env, ...probes });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const u24 = (b, at) => b[at] | (b[at + 1] << 8) | (b[at + 2] << 16);
|
|
19
|
+
|
|
20
|
+
export function parseWebp(bytes) {
|
|
21
|
+
if (bytes.length < 16 || bytes.toString('latin1', 0, 4) !== 'RIFF' || bytes.toString('latin1', 8, 12) !== 'WEBP') throw new Error('not a WebP file');
|
|
22
|
+
const info = { width: 0, height: 0, loop: null, animated: false, frames: [] };
|
|
23
|
+
const end = Math.min(bytes.length, 8 + bytes.readUInt32LE(4));
|
|
24
|
+
for (let at = 12; at + 8 <= end;) {
|
|
25
|
+
const kind = bytes.toString('latin1', at, at + 4), size = bytes.readUInt32LE(at + 4), body = bytes.subarray(at + 8, at + 8 + size);
|
|
26
|
+
if (kind === 'VP8X') { info.animated = Boolean(body[0] & 2); info.width = 1 + u24(body, 4); info.height = 1 + u24(body, 7); }
|
|
27
|
+
else if (kind === 'ANIM') info.loop = body.readUInt16LE(4);
|
|
28
|
+
else if (kind === 'ANMF') {
|
|
29
|
+
info.frames.push({ x: 2 * u24(body, 0), y: 2 * u24(body, 3), w: 1 + u24(body, 6), h: 1 + u24(body, 9), ms: u24(body, 12), blend: !(body[15] & 2), dispose: Boolean(body[15] & 1), data: body.subarray(16) });
|
|
30
|
+
} else if ((kind === 'VP8 ' || kind === 'VP8L') && !info.frames.length) {
|
|
31
|
+
info.frames.push({ x: 0, y: 0, w: 0, h: 0, ms: 0, blend: false, dispose: false, still: true, data: bytes.subarray(at, at + 8 + size + (size & 1)) });
|
|
32
|
+
}
|
|
33
|
+
at += 8 + size + (size & 1);
|
|
34
|
+
}
|
|
35
|
+
if (!info.animated && info.loop === null) info.loop = 0;
|
|
36
|
+
return info;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One ANMF payload (ALPH?, then VP8/VP8L) as a standalone WebP file.
|
|
40
|
+
function asStill(frame) {
|
|
41
|
+
const alpha = frame.data.toString('latin1', 0, 4) === 'ALPH';
|
|
42
|
+
const parts = [frame.data];
|
|
43
|
+
if (alpha) {
|
|
44
|
+
const vp8x = Buffer.alloc(18);
|
|
45
|
+
vp8x.write('VP8X', 0, 'latin1'); vp8x.writeUInt32LE(10, 4); vp8x[8] = 0x10;
|
|
46
|
+
vp8x.writeUIntLE(frame.w - 1, 12, 3); vp8x.writeUIntLE(frame.h - 1, 15, 3);
|
|
47
|
+
parts.unshift(vp8x);
|
|
48
|
+
}
|
|
49
|
+
const head = Buffer.alloc(12);
|
|
50
|
+
head.write('RIFF', 0, 'latin1'); head.writeUInt32LE(4 + parts.reduce((n, p) => n + p.length, 0), 4); head.write('WEBP', 8, 'latin1');
|
|
51
|
+
return Buffer.concat([head, ...parts]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// dwebp -pam output: { width, height, rgba }.
|
|
55
|
+
export function readPam(buf) {
|
|
56
|
+
const end = buf.indexOf('ENDHDR\n');
|
|
57
|
+
if (buf.toString('latin1', 0, 2) !== 'P7' || end < 0) throw new Error('not a PAM image');
|
|
58
|
+
const head = buf.toString('latin1', 0, end);
|
|
59
|
+
const field = (name) => Number(new RegExp(`${name} (\\d+)`).exec(head)?.[1]);
|
|
60
|
+
const width = field('WIDTH'), height = field('HEIGHT'), depth = field('DEPTH');
|
|
61
|
+
const data = buf.subarray(end + 7);
|
|
62
|
+
if (depth === 4) return { width, height, rgba: data };
|
|
63
|
+
const rgba = Buffer.alloc(width * height * 4, 255);
|
|
64
|
+
for (let i = 0; i < width * height; i++) for (let c = 0; c < 3; c++) rgba[i * 4 + c] = data[i * depth + c];
|
|
65
|
+
return { width, height, rgba };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Non-premultiplied alpha blend (as libwebp), or a straight replace when the frame does not blend.
|
|
69
|
+
function paint(canvas, W, frame, rgba) {
|
|
70
|
+
for (let y = 0; y < frame.h; y++) {
|
|
71
|
+
for (let x = 0; x < frame.w; x++) {
|
|
72
|
+
const s = (y * frame.w + x) * 4, d = ((frame.y + y) * W + frame.x + x) * 4, sa = rgba[s + 3];
|
|
73
|
+
if (!frame.blend || sa === 255) { canvas[d] = rgba[s]; canvas[d + 1] = rgba[s + 1]; canvas[d + 2] = rgba[s + 2]; canvas[d + 3] = sa; continue; }
|
|
74
|
+
if (!sa) continue;
|
|
75
|
+
const keep = (canvas[d + 3] * (255 - sa)) / 255, a = sa + keep;
|
|
76
|
+
for (let c = 0; c < 3; c++) canvas[d + c] = Math.round((rgba[s + c] * sa + canvas[d + c] * keep) / a);
|
|
77
|
+
canvas[d + 3] = Math.round(a);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Decode every frame; onFrame(index, rgba, ms, width, height) sees the composited canvas, which is
|
|
83
|
+
// reused between calls (copy what you keep). Returns { width, height, loop, durations, bytes }.
|
|
84
|
+
export function decodeWebp(file, { env = process.env, onFrame = () => {} } = {}) {
|
|
85
|
+
const dwebp = which('dwebp', env);
|
|
86
|
+
if (!dwebp) throw new Error('dwebp not found (it ships with img2webp in libwebp)');
|
|
87
|
+
const bytes = readFileSync(file);
|
|
88
|
+
const info = parseWebp(bytes);
|
|
89
|
+
if (!info.frames.length) throw new Error(`${file} holds no frames`);
|
|
90
|
+
const work = mkdtempSync(join(tmpdir(), 'lithermes-webp-'));
|
|
91
|
+
try {
|
|
92
|
+
let W = info.width, H = info.height, canvas = null, previous = null;
|
|
93
|
+
const durations = [];
|
|
94
|
+
info.frames.forEach((frame, i) => {
|
|
95
|
+
const src = join(work, 'f.webp'), pam = join(work, 'f.pam');
|
|
96
|
+
writeFileSync(src, frame.still ? Buffer.concat([bytes.subarray(0, 12), frame.data]) : asStill(frame));
|
|
97
|
+
const run = spawnSync(dwebp, [src, '-pam', '-quiet', '-o', pam], { encoding: 'utf8', env });
|
|
98
|
+
if (run.status !== 0) throw new Error(`dwebp failed on frame ${i}: ${(run.stderr || run.error?.message || '').trim().split(/\r?\n/)[0]}`);
|
|
99
|
+
const still = readPam(readFileSync(pam));
|
|
100
|
+
if (frame.still) { W = still.width; H = still.height; frame.w = W; frame.h = H; }
|
|
101
|
+
if (!canvas) canvas = Buffer.alloc(W * H * 4);
|
|
102
|
+
if (previous?.dispose) for (let y = previous.y; y < previous.y + previous.h; y++) canvas.fill(0, (y * W + previous.x) * 4, (y * W + previous.x + previous.w) * 4);
|
|
103
|
+
paint(canvas, W, frame, still.rgba);
|
|
104
|
+
durations.push(frame.ms);
|
|
105
|
+
onFrame(i, canvas, frame.ms, W, H);
|
|
106
|
+
previous = frame;
|
|
107
|
+
});
|
|
108
|
+
return { width: W, height: H, loop: info.loop, durations, bytes: statSync(file).size };
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(work, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// One cover frame off the capture thread: decode the 2x capture, Lanczos it down to the cover size,
|
|
2
|
+
// write it as an RGB PNG (the encoder's input and what G0 and the seek-order check hash), and
|
|
3
|
+
// return that PNG's SHA-256 with the size that was captured.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { writeFileSync } from 'node:fs';
|
|
6
|
+
import { parentPort } from 'node:worker_threads';
|
|
7
|
+
import { lanczosResize } from './cover-image.mjs';
|
|
8
|
+
import { decodePng, encodePng } from './cover-png.mjs';
|
|
9
|
+
|
|
10
|
+
parentPort.on('message', ({ id, png, width, height, file }) => {
|
|
11
|
+
try {
|
|
12
|
+
const src = decodePng(Buffer.from(png));
|
|
13
|
+
const bytes = encodePng(lanczosResize(src.rgba, src.width, src.height, width, height), width, height, { level: 1 });
|
|
14
|
+
if (file) writeFileSync(file, bytes);
|
|
15
|
+
parentPort.postMessage({ id, sha256: createHash('sha256').update(bytes).digest('hex'), captured: `${src.width}x${src.height}` });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
parentPort.postMessage({ id, error: String(error.message || error) });
|
|
18
|
+
}
|
|
19
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Cover mode: a silent, seamless README cover loop (references/cover.md). One round runs, in this
|
|
2
|
+
// order: the cover treatment (exit 16), the page's static scan (17, 19), the asset list (22 for a
|
|
3
|
+
// missing asset, before any capture), the encoder probe (21) and the pre-warmed driver (14, 15);
|
|
4
|
+
// then the master Chrome seeks t = i / N for every frame at device scale 2 and a worker pool
|
|
5
|
+
// brings each capture down to the cover size; the same browser draws the title mask of every
|
|
6
|
+
// frame; a fresh Chrome repeats every frame (G0); another fresh Chrome draws 16 frames in a seeded
|
|
7
|
+
// shuffled order, which must match the master (a page that keeps state between frames fails here,
|
|
8
|
+
// exit 18); the flash audit runs on the frames as a loop; img2webp encodes (frame 0 and the frames
|
|
9
|
+
// before the seam at a higher quality; gifski is the fallback, never an MP4); the gate measures
|
|
10
|
+
// the DECODED file; the decoded stills, the seam pair and the sheet are written, then the
|
|
11
|
+
// manifest, the report and run-state.json. A flash-audit FAIL withholds the cover. `--window`
|
|
12
|
+
// renders and sheets only the frames around a logged defect and never counts as done.
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
15
|
+
import { existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { COVER, EXIT, FLASH } from './constants.mjs';
|
|
19
|
+
import { FlashDetector, flashesFromRecords } from './flash.mjs';
|
|
20
|
+
import { artCheck, checkAssets } from './cover-assets.mjs';
|
|
21
|
+
import { capturePass, CoverPool, frameName, openCover } from './cover-capture.mjs';
|
|
22
|
+
import { coverGate, coverVerdict, finishGate, formatCoverReport, framesIn, readPng } from './cover-gate.mjs';
|
|
23
|
+
import { lumaGrid } from './cover-image.mjs';
|
|
24
|
+
import { encodePng } from './cover-png.mjs';
|
|
25
|
+
import { writeCoverSheet } from './cover-sheet.mjs';
|
|
26
|
+
import { loadCoverTreatment, validateCoverTreatment } from './cover-treatment.mjs';
|
|
27
|
+
import { which } from './cover-webp.mjs';
|
|
28
|
+
import { recordFirstTreatment, writeJson, writeRunState } from './director.mjs';
|
|
29
|
+
import { fontRoutes, scanStage } from './stage.mjs';
|
|
30
|
+
import { stampStills } from './stills.mjs';
|
|
31
|
+
import { findCache, fontState, runtimeRequire, WARM } from '../bin/runtime.mjs';
|
|
32
|
+
|
|
33
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
const sha = (data) => createHash('sha256').update(data).digest('hex');
|
|
35
|
+
const fail = (code, message) => Object.assign(new Error(message), { exitCode: code });
|
|
36
|
+
|
|
37
|
+
export const COVER_DIR = 'cover';
|
|
38
|
+
export const SHEET_TILES = 12;
|
|
39
|
+
export const frameMs = (fps) => Math.round(1000 / fps);
|
|
40
|
+
export const loopTimes = (frames) => Array.from({ length: frames }, (_, i) => [i, i / frames]);
|
|
41
|
+
|
|
42
|
+
// img2webp: lossy, loop forever, every frame -d round(1000 / fps) ms, frame 0 and the last
|
|
43
|
+
// edgeFrames frames at edgeQ (the seam's two sides carry the least requantisation error).
|
|
44
|
+
export function img2webpArgs({ files, fps, encode, output }) {
|
|
45
|
+
const args = ['-loop', '0', '-lossy', '-q', String(encode.q), '-m', String(encode.method)];
|
|
46
|
+
files.forEach((file, i) => args.push('-d', String(frameMs(fps)), '-q', String(i === 0 || i >= files.length - encode.edgeFrames ? encode.edgeQ : encode.q), file));
|
|
47
|
+
return [...args, '-o', output];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function coverEncoder(env = process.env) {
|
|
51
|
+
const img2webp = which('img2webp', env), dwebp = which('dwebp', env);
|
|
52
|
+
if (img2webp && dwebp) return { kind: 'img2webp', bin: img2webp };
|
|
53
|
+
const gifski = which('gifski', env);
|
|
54
|
+
return gifski ? { kind: 'gifski', bin: gifski } : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function encodeCover({ files, fps, encode, workDir, env }) {
|
|
58
|
+
const encoder = coverEncoder(env);
|
|
59
|
+
if (!encoder) throw fail(EXIT.BLOCKED_NO_COVER_ENCODER, 'BLOCKED_NO_COVER_ENCODER: install libwebp (img2webp and dwebp) or gifski; a cover is never an MP4');
|
|
60
|
+
const output = join(workDir, encoder.kind === 'img2webp' ? 'cover.webp' : 'cover.gif');
|
|
61
|
+
const args = encoder.kind === 'img2webp' ? img2webpArgs({ files, fps, encode, output }) : ['--fps', String(fps), '--quality', '90', '--repeat', '0', '-o', output, ...files];
|
|
62
|
+
const run = spawnSync(encoder.bin, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env });
|
|
63
|
+
if (run.status !== 0) throw fail(1, `${encoder.kind} failed: ${(run.stderr || run.error?.message || '').trim().split(/\r?\n/).at(-1)}`);
|
|
64
|
+
return { file: output, encoder: encoder.kind };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// mulberry32-seeded Fisher-Yates over the frame indices; the first `count` are re-drawn.
|
|
68
|
+
export function seekOrder(frames, seed, count = COVER.seekSamples) {
|
|
69
|
+
let s = seed >>> 0;
|
|
70
|
+
const next = () => { s = (s + 0x6d2b79f5) >>> 0; let z = Math.imul(s ^ (s >>> 15), s | 1); z ^= z + Math.imul(z ^ (z >>> 7), z | 61); return ((z ^ (z >>> 14)) >>> 0) / 4294967296; };
|
|
71
|
+
const all = Array.from({ length: frames }, (_, i) => i);
|
|
72
|
+
for (let i = frames - 1; i > 0; i--) { const j = Math.floor(next() * (i + 1)); [all[i], all[j]] = [all[j], all[i]]; }
|
|
73
|
+
return all.slice(0, Math.min(count, frames));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function maxLevelDiff(fileA, fileB) {
|
|
77
|
+
const a = readPng(fileA), b = readPng(fileB);
|
|
78
|
+
if (a.width !== b.width || a.height !== b.height) return 255;
|
|
79
|
+
let max = 0;
|
|
80
|
+
for (let i = 0; i < a.rgba.length; i++) { const d = Math.abs(a.rgba[i] - b.rgba[i]); if (d > max) max = d; }
|
|
81
|
+
return max;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// WCAG 2.3.1 on the frames as a loop: the last two frames prime the detector, so frame 0 is judged
|
|
85
|
+
// against the frame the reader saw just before it.
|
|
86
|
+
function flashAudit(files, fps) {
|
|
87
|
+
const first = readPng(files[0]);
|
|
88
|
+
const detector = new FlashDetector(first.width, first.height);
|
|
89
|
+
const order = [files.length - 2, files.length - 1, ...files.keys()].filter((i) => i >= 0);
|
|
90
|
+
const records = [];
|
|
91
|
+
order.forEach((i, k) => {
|
|
92
|
+
const r = detector.push(i === 0 ? first.rgba : readPng(files[i]).rgba);
|
|
93
|
+
if (k >= order.length - files.length) records.push({ frame: i, ...r });
|
|
94
|
+
});
|
|
95
|
+
const counted = flashesFromRecords(records, fps, { loop: true });
|
|
96
|
+
return { pass: counted.general <= FLASH.maxFlashes && counted.red <= FLASH.maxRedFlashes, general: counted.general, red: counted.red, worstAtSec: counted.worstGeneral?.startSec ?? 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Everything checked before the first capture.
|
|
100
|
+
function prepare(out, env) {
|
|
101
|
+
const plan = loadCoverTreatment(out);
|
|
102
|
+
recordFirstTreatment(out, plan.treatment);
|
|
103
|
+
const coverDir = join(out, COVER_DIR);
|
|
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);
|
|
106
|
+
if (scan.contract.length) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${scan.contract.join('; ')}`);
|
|
107
|
+
if (scan.network.length) throw fail(EXIT.STAGE_NETWORK_REQUEST, `STAGE_NETWORK_REQUEST: ${scan.network.join('; ')}; a cover is served offline`);
|
|
108
|
+
const assets = checkAssets({ out, recipe: plan.treatment.recipe, rasters: scan.rasters.paths, coverDir: COVER_DIR });
|
|
109
|
+
if (!coverEncoder(env)) throw fail(EXIT.BLOCKED_NO_COVER_ENCODER, 'BLOCKED_NO_COVER_ENCODER: install libwebp (img2webp and dwebp) or gifski; a cover is never an MP4');
|
|
110
|
+
const runtime = runtimeRequire(env);
|
|
111
|
+
const fonts = fontState(env);
|
|
112
|
+
if (!fonts.ok) throw fail(EXIT.BLOCKED_FONT_FETCH, `BLOCKED_FONT_FETCH: ${[...fonts.missing.map((f) => `missing ${f}`), ...fonts.mismatched.map((f) => `sha256 mismatch ${f}`)].join('; ')}; run \`${WARM}\` outside this session`);
|
|
113
|
+
return { ...plan, coverDir, scan, assets, runtime, fonts: fontRoutes(findCache(env)) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const open = (p, tag, runDir, env) => openCover({ runtime: p.runtime, coverDir: p.coverDir, fonts: p.fonts, width: p.page[0], height: p.page[1], scale: COVER.captureScale, profileRoot: join(runDir, `chrome-${tag}-${process.pid % 100000}`), env });
|
|
117
|
+
|
|
118
|
+
// The decoded frame on screen at `ms`.
|
|
119
|
+
export function frameAt(durations, ms) {
|
|
120
|
+
let end = 0;
|
|
121
|
+
for (let i = 0; i < durations.length; i++) { end += durations[i]; if (ms < end) return i; }
|
|
122
|
+
return durations.length - 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// stills/: twelve decoded frames at t = k/12, the poster (decoded frame 0), the seam pair (last
|
|
126
|
+
// decoded frame beside frame 0) and the sheet, listed in stills/stills.json like the film set.
|
|
127
|
+
function writeCoverStills({ out, round, gate }) {
|
|
128
|
+
const dir = join(out, 'stills');
|
|
129
|
+
rmSync(dir, { recursive: true, force: true });
|
|
130
|
+
mkdirSync(dir, { recursive: true });
|
|
131
|
+
const { width: W, height: H, durations, kept } = gate;
|
|
132
|
+
const total = durations.reduce((a, b) => a + b, 0);
|
|
133
|
+
const files = [], tiles = [];
|
|
134
|
+
const put = (name, bytes, extra) => { writeFileSync(join(dir, name), bytes); files.push({ file: `stills/${name}`, sha256: sha(bytes), ...extra }); };
|
|
135
|
+
for (let k = 0; k < SHEET_TILES; k++) {
|
|
136
|
+
const index = frameAt(durations, (k / SHEET_TILES) * total);
|
|
137
|
+
put(`frame-${String(k).padStart(2, '0')}.png`, encodePng(kept.get(index), W, H), { kind: 'frame', t: Number(((k / SHEET_TILES) * total / 1000).toFixed(3)), decodedFrame: index });
|
|
138
|
+
tiles.push({ rgba: kept.get(index), width: W, height: H, t: (k / SHEET_TILES) * total / 1000 });
|
|
139
|
+
}
|
|
140
|
+
put('poster.png', encodePng(kept.get(0), W, H, { level: 9 }), { kind: 'poster', decodedFrame: 0 });
|
|
141
|
+
const last = durations.length - 1, pair = Buffer.alloc(2 * W * H * 4);
|
|
142
|
+
for (let y = 0; y < H; y++) {
|
|
143
|
+
kept.get(last).copy(pair, y * 2 * W * 4, y * W * 4, (y + 1) * W * 4);
|
|
144
|
+
kept.get(0).copy(pair, (y * 2 * W + W) * 4, y * W * 4, (y + 1) * W * 4);
|
|
145
|
+
}
|
|
146
|
+
put('seam.png', encodePng(pair, 2 * W, H), { kind: 'seam', decodedFrames: [last, 0] });
|
|
147
|
+
writeCoverSheet({ file: join(dir, 'sheet.png'), tiles, diffs: gate.diffs, medianD: gate.medianD, durationSec: gate.durationSec });
|
|
148
|
+
const sheet = readFileSync(join(dir, 'sheet.png'));
|
|
149
|
+
files.push({ file: 'stills/sheet.png', sha256: sha(sheet), kind: 'sheet' });
|
|
150
|
+
const set = { schema: 'lithermes.stills/v1', round, mode: 'cover', size: [W, H], durationSec: total / 1000, files, required: files.filter((f) => ['sheet', 'poster', 'seam'].includes(f.kind)).map((f) => f.file), manifestSha256: null };
|
|
151
|
+
writeJson(join(dir, 'stills.json'), set);
|
|
152
|
+
return set;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function renderCover({ out, round = 1, env = process.env, log = (line) => process.stderr.write(`${line}\n`) }) {
|
|
156
|
+
const p = prepare(out, env);
|
|
157
|
+
const runDir = join(out, '.run', 'cover');
|
|
158
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
159
|
+
const dirs = Object.fromEntries(['frames', 'masks', 'frames2', 'seek'].map((k) => [k, join(runDir, k)]));
|
|
160
|
+
const times = loopTimes(p.frames), order = seekOrder(p.frames, p.seed);
|
|
161
|
+
const pool = new CoverPool();
|
|
162
|
+
const pass = (browser, list, dir, prefix, mask = false) => capturePass({ browser, pool, times: list, dir, prefix, mask, size: p.size, page: p.page, scale: COVER.captureScale });
|
|
163
|
+
const started = Date.now();
|
|
164
|
+
let master, replay, sought;
|
|
165
|
+
try {
|
|
166
|
+
let b = await open(p, 'master', runDir, env);
|
|
167
|
+
try { master = await pass(b, times, dirs.frames, 'f'); await pass(b, times, dirs.masks, 'm', true); } finally { await b.close(); }
|
|
168
|
+
log(`cover: master and mask passes done (${p.frames} frames)`);
|
|
169
|
+
b = await open(p, 'replay', runDir, env);
|
|
170
|
+
try { replay = await pass(b, times, dirs.frames2, 'f'); } finally { await b.close(); }
|
|
171
|
+
b = await open(p, 'seek', runDir, env);
|
|
172
|
+
try { sought = await pass(b, order.map((i) => times[i]), dirs.seek, 's'); } finally { await b.close(); }
|
|
173
|
+
} finally { await pool.close(); }
|
|
174
|
+
const captureSec = (Date.now() - started) / 1000;
|
|
175
|
+
const ms = frameMs(p.treatment.fps);
|
|
176
|
+
const differing = times.filter(([i]) => master.get(i) !== replay.get(i));
|
|
177
|
+
if (differing.length) throw fail(EXIT.STAGE_NONDETERMINISTIC, `STAGE_NONDETERMINISTIC: a fresh capture differs at ${differing.slice(0, 4).map(([i, t]) => `frame ${i} (t = ${t.toFixed(4)})`).join(', ')}${differing.length > 4 ? ` (+${differing.length - 4})` : ''}; draw every pixel from t and LitLoop.rng(seed)`);
|
|
178
|
+
// A sought frame within one level per channel is the rasterizer's rounding when opacity and a
|
|
179
|
+
// transform change together; state kept between frames differs by far more.
|
|
180
|
+
let oneLevel = 0;
|
|
181
|
+
for (const [k, i] of order.entries()) {
|
|
182
|
+
if (sought.get(i) === master.get(i)) continue;
|
|
183
|
+
const levels = maxLevelDiff(join(dirs.frames, frameName('f', i)), join(dirs.seek, frameName('s', i)));
|
|
184
|
+
if (levels <= COVER.seekLevels) { oneLevel++; continue; }
|
|
185
|
+
throw fail(EXIT.STAGE_NONDETERMINISTIC, `STAGE_NONDETERMINISTIC: frame ${i} (t = ${((i * ms) / 1000).toFixed(3)} s) differs by up to ${levels} levels when sought out of order (a fresh page drew it ${k ? `after frame ${order[k - 1]}` : 'first'}); the page keeps state between frames: clear every canvas and draw each frame from t alone`);
|
|
186
|
+
}
|
|
187
|
+
const files = framesIn(dirs.frames, 'f');
|
|
188
|
+
const flash = flashAudit(files, p.treatment.fps);
|
|
189
|
+
const encoded = encodeCover({ files, fps: p.treatment.fps, encode: p.encode, workDir: runDir, env });
|
|
190
|
+
const planned = p.frames * ms;
|
|
191
|
+
const wanted = Array.from({ length: SHEET_TILES }, (_, k) => (k / SHEET_TILES) * planned);
|
|
192
|
+
let clock = 0;
|
|
193
|
+
const keep = (i, dur) => { const start = clock; clock += dur; return i === 0 || start + dur >= planned - 1e-6 || wanted.some((x) => x >= start && x < start + dur); };
|
|
194
|
+
const paintedTitle = p.treatment.title.kind === 'painted' ? p.assets.find((a) => a.role === 'base-art' && a.title) : null;
|
|
195
|
+
const measured = coverGate(encoded.file, { frames: dirs.frames, frames2: dirs.frames2, masks: dirs.masks, title: p.treatment.title.box, glyph: { exempt: paintedTitle ? `painted into the declared base art ${paintedTitle.path}` : null }, fps: p.treatment.fps, keep, env });
|
|
196
|
+
const gates = { ...measured.gates, SEEK: { pass: true, sampled: order.length, identical: order.length - oneLevel, oneLevel } };
|
|
197
|
+
for (const a of p.assets.filter((x) => x.role === 'base-art')) {
|
|
198
|
+
let art = null;
|
|
199
|
+
try { art = readPng(join(out, a.path)); } catch { art = null; }
|
|
200
|
+
const check = art && artCheck({ frame0: measured.kept.get(0), width: measured.width, height: measured.height, art, box: a.box, checkBox: a.checkBox });
|
|
201
|
+
gates.ART = check ? { pass: check.pass, ssim: check.ssim, checked: check.checked.join(',') } : { pass: false, reason: `${a.path} is not a PNG this gate can read; list a PNG base art` };
|
|
202
|
+
}
|
|
203
|
+
gates.FLASH = { pass: flash.pass, generalFlashes: flash.general, redFlashes: flash.red, worstAtSec: flash.worstAtSec };
|
|
204
|
+
const gate = finishGate({ input: encoded.file, src: measured.src, m: measured.m, gates });
|
|
205
|
+
const stills = writeCoverStills({ out, round, gate });
|
|
206
|
+
const name = basename(encoded.file);
|
|
207
|
+
for (const stale of ['cover.webp', 'cover.gif', 'poster.png']) rmSync(join(out, stale), { force: true });
|
|
208
|
+
const destination = flash.pass ? out : join(out, 'withheld');
|
|
209
|
+
mkdirSync(destination, { recursive: true });
|
|
210
|
+
renameSync(encoded.file, join(destination, name));
|
|
211
|
+
if (flash.pass) {
|
|
212
|
+
writeFileSync(join(out, 'poster.png'), readFileSync(join(out, 'stills', 'poster.png')));
|
|
213
|
+
rmSync(join(out, 'withheld'), { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
const verdict = coverVerdict(gate);
|
|
216
|
+
const missed = Object.entries(gate.gates).filter(([, g]) => g.pass === false || g.target === false).map(([id]) => id);
|
|
217
|
+
const report = `${formatCoverReport(gate, { strict: true })}${flash.pass ? '' : ` WITHHELD: the flash audit failed; ${name} sits in withheld/ and is not a deliverable\n`}`;
|
|
218
|
+
writeFileSync(join(out, 'gate-report.txt'), report);
|
|
219
|
+
const manifest = {
|
|
220
|
+
schemaVersion: 1, path: 'cover', mode: 'cover', round, recipe: p.treatment.recipe,
|
|
221
|
+
treatment: { file: p.file, sha256: sha(readFileSync(p.file)) }, page: p.page, size: p.size, scale: COVER.captureScale,
|
|
222
|
+
fps: p.treatment.fps, frames: p.frames, frameMs: ms, encoder: encoded.encoder, encode: p.encode, file: flash.pass ? name : `withheld/${name}`,
|
|
223
|
+
bytes: gate.bytes, captureSec, stills: stills.files.length, assets: p.assets.map(({ path, role, source }) => ({ path, role, source })),
|
|
224
|
+
gate: { verdict, medianD: gate.medianD, movingFrac: gate.movingFrac, longestStillSec: gate.longestStillSec, spike: gate.spike, seamLocal: gate.seamLocal, missed },
|
|
225
|
+
generatedAt: new Date().toISOString(),
|
|
226
|
+
};
|
|
227
|
+
writeJson(join(out, 'manifest.json'), manifest);
|
|
228
|
+
stampStills(out);
|
|
229
|
+
const code = verdict === 'PASS+TARGET' && flash.pass ? EXIT.OK : EXIT.GATE_FAIL_QA;
|
|
230
|
+
writeRunState(out, { exitCode: code, mode: 'cover', round, finished: true, failed: missed, withheld: !flash.pass, path: 'cover' });
|
|
231
|
+
const summary = `cover: ${manifest.file} ${gate.bytes} bytes, ${p.frames} frames at ${p.treatment.fps} fps, ${p.size.join('x')} (captured ${p.page[0] * COVER.captureScale}x${p.page[1] * COVER.captureScale}) in ${captureSec.toFixed(0)} s; stills and sheet in ${join(out, 'stills')}`;
|
|
232
|
+
return { code, verdict, report, summary, missed, withheld: !flash.pass };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The master pass alone, encoded and gated on the decoded file (G1-G4): how the shipped negatives
|
|
236
|
+
// and the recipe rows are re-checked without a whole round. treatment is a cover treatment object.
|
|
237
|
+
export async function renderQuick({ coverDir, treatment, workDir, env = process.env }) {
|
|
238
|
+
const valid = validateCoverTreatment(treatment);
|
|
239
|
+
if (!valid.ok) throw fail(EXIT.BLOCKED_TREATMENT_INVALID, `BLOCKED_TREATMENT_INVALID: field ${valid.field}: ${valid.message}`);
|
|
240
|
+
const p = { ...valid.plan, treatment, coverDir, runtime: runtimeRequire(env), fonts: fontRoutes(findCache(env)) };
|
|
241
|
+
const dir = join(workDir, 'frames');
|
|
242
|
+
rmSync(dir, { recursive: true, force: true });
|
|
243
|
+
const pool = new CoverPool();
|
|
244
|
+
const browser = await open(p, 'quick', workDir, env);
|
|
245
|
+
try {
|
|
246
|
+
await capturePass({ browser, pool, times: loopTimes(p.frames), dir, prefix: 'f', size: p.size, page: p.page, scale: COVER.captureScale });
|
|
247
|
+
} finally { await browser.close(); await pool.close(); }
|
|
248
|
+
const encoded = encodeCover({ files: framesIn(dir, 'f'), fps: treatment.fps, encode: p.encode, workDir, env });
|
|
249
|
+
return coverGate(encoded.file, { env });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// --window t0,t1 (seconds into the loop): capture only the frames from t0 - 0.5 s to t1 + 0.5 s,
|
|
253
|
+
// wrapping at the seam, in a fresh page, and sheet them with the span shaded. It writes only under
|
|
254
|
+
// windows/round-N/; the stills, the cover file, the report and the manifest stay as they were.
|
|
255
|
+
export async function renderCoverWindow({ out, round = 1, span: [t0, t1], env = process.env }) {
|
|
256
|
+
const p = prepare(out, env);
|
|
257
|
+
const ms = frameMs(p.treatment.fps), loop = (p.frames * ms) / 1000;
|
|
258
|
+
if (!(Number.isFinite(t0) && Number.isFinite(t1) && t0 >= 0 && t1 >= t0 && t1 <= loop)) throw fail(2, `--window takes t0,t1 in seconds with 0 <= t0 <= t1 <= ${loop.toFixed(3)}`);
|
|
259
|
+
const lo = t0 - COVER.windowPadSec, hi = t1 + COVER.windowPadSec, picks = [];
|
|
260
|
+
for (let i = 0; i < p.frames; i++) for (const lap of [-1, 0, 1]) {
|
|
261
|
+
const t = (i * ms) / 1000 + lap * loop;
|
|
262
|
+
if (t >= lo - 1e-9 && t <= hi + 1e-9) picks.push({ index: i, t: Number(t.toFixed(3)) });
|
|
263
|
+
}
|
|
264
|
+
picks.sort((a, b) => a.t - b.t);
|
|
265
|
+
const dir = join(out, 'windows', `round-${round}`, `${t0.toFixed(3)}-${t1.toFixed(3)}`);
|
|
266
|
+
rmSync(dir, { recursive: true, force: true });
|
|
267
|
+
const pool = new CoverPool();
|
|
268
|
+
const browser = await open(p, 'window', join(out, '.run', 'cover-window'), env);
|
|
269
|
+
try {
|
|
270
|
+
await capturePass({ browser, pool, times: [...new Set(picks.map((x) => x.index))].map((i) => [i, i / p.frames]), dir, prefix: 'f', size: p.size, page: p.page, scale: COVER.captureScale });
|
|
271
|
+
} finally { await browser.close(); await pool.close(); }
|
|
272
|
+
const images = picks.map((x) => readPng(join(dir, frameName('f', x.index))));
|
|
273
|
+
const lumas = images.map((img) => lumaGrid(img.rgba, img.width, img.height).luma);
|
|
274
|
+
const diffs = lumas.slice(1).map((l, k) => { let s = 0; for (let i = 0; i < l.length; i++) s += Math.abs(l[i] - lumas[k][i]); return s / l.length / 255; });
|
|
275
|
+
const ordered = [...diffs].sort((a, b) => a - b);
|
|
276
|
+
const count = Math.min(SHEET_TILES, picks.length);
|
|
277
|
+
const tiles = Array.from({ length: count }, (_, k) => Math.round((k * (picks.length - 1)) / Math.max(1, count - 1)))
|
|
278
|
+
.map((j) => ({ rgba: images[j].rgba, width: images[j].width, height: images[j].height, t: picks[j].t }));
|
|
279
|
+
writeCoverSheet({ file: join(dir, 'sheet.png'), tiles, diffs: diffs.length ? diffs : [0], medianD: ordered[ordered.length >> 1] ?? 0, durationSec: hi - lo, marks: [{ t0: t0 - lo, t1: t1 - lo }] });
|
|
280
|
+
writeJson(join(dir, 'window.json'), { schema: 'lithermes.cover-window/v1', round, t0, t1, padSec: COVER.windowPadSec, frames: picks, note: 'frames before encoding; a window render never counts as done' });
|
|
281
|
+
return { code: EXIT.OK, summary: `WINDOW round ${round}: ${picks.length} frames from ${lo.toFixed(3)} to ${hi.toFixed(3)} s; sheet ${join(dir, 'sheet.png')}\na window render never counts as done: fix the cause, then render the full cover` };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function coverCommand(flags) {
|
|
285
|
+
const out = resolve(flags.out || 'motion-output');
|
|
286
|
+
const round = Number(flags.round || 1);
|
|
287
|
+
if (flags.detach) return detach(out, round);
|
|
288
|
+
try {
|
|
289
|
+
if (![1, 2, 3].includes(round)) throw fail(2, '--round must be 1, 2 or 3');
|
|
290
|
+
if (flags.window) {
|
|
291
|
+
const span = String(flags.window).split(',').map(Number);
|
|
292
|
+
if (span.length !== 2) throw fail(2, '--window takes t0,t1 in seconds');
|
|
293
|
+
const r = await renderCoverWindow({ out, round, span });
|
|
294
|
+
console.log(r.summary);
|
|
295
|
+
return r.code;
|
|
296
|
+
}
|
|
297
|
+
writeRunState(out, { exitCode: null, mode: 'cover', round, finished: false, path: 'cover' });
|
|
298
|
+
const r = await renderCover({ out, round });
|
|
299
|
+
console.log(`${r.report}${r.summary}`);
|
|
300
|
+
if (r.code === EXIT.OK) console.log('View stills/sheet.png, stills/poster.png and stills/seam.png with vision_analyze, then record the round with `look`.');
|
|
301
|
+
else console.log(`GATE_FAIL_QA: ${r.missed.join(', ')} ${r.verdict === 'FAIL' ? 'fail' : 'miss the cover TARGET'}${r.withheld ? ' (flash audit failed: the cover is withheld)' : ''}; ${round >= 3 ? 'round 3 of 3: deliver with the open items stated plainly' : `fix the cause and render --round ${round + 1}`}`);
|
|
302
|
+
return r.code;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
const code = error.exitCode ?? 1;
|
|
305
|
+
console.error(error.exitCode ? error.message : `motion internal error: ${error.stack || error.message}`);
|
|
306
|
+
if (!flags.window && existsSync(out)) writeRunState(out, { exitCode: code, mode: 'cover', round, finished: true, failed: [], path: 'cover', message: String(error.message).split('\n')[0] });
|
|
307
|
+
return code;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// `cover-gate FILE`: the same gate on any cover file or PNG frame folder.
|
|
312
|
+
export function coverGateCommand(flags) {
|
|
313
|
+
const target = flags._[1];
|
|
314
|
+
if (!target) { console.error('cover-gate needs a cover file (cover.webp, cover.gif) or a PNG frame folder'); return 2; }
|
|
315
|
+
try {
|
|
316
|
+
const r = coverGate(resolve(target), {
|
|
317
|
+
frames: flags.frames ? resolve(flags.frames) : null, frames2: flags.frames2 ? resolve(flags.frames2) : null, masks: flags.masks ? resolve(flags.masks) : null,
|
|
318
|
+
title: flags.title ? String(flags.title).split(',').map(Number) : null, fps: Number(flags.fps || 16),
|
|
319
|
+
});
|
|
320
|
+
if (flags.json) { const { kept, src, m, diffs, durations, ...row } = r; console.log(JSON.stringify(row, null, 1)); }
|
|
321
|
+
else console.log(formatCoverReport(r, { strict: Boolean(flags.strict) }).trimEnd());
|
|
322
|
+
return r.pass && (!flags.strict || r.target) ? EXIT.OK : EXIT.GATE_FAIL_QA;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
console.error(`cover-gate: ${error.message}`);
|
|
325
|
+
return 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// --detach: the same render in a detached child that leaves run-state.json; the caller polls it.
|
|
330
|
+
function detach(out, round) {
|
|
331
|
+
mkdirSync(join(out, '.run'), { recursive: true });
|
|
332
|
+
const log = join(out, '.run', 'cover.log');
|
|
333
|
+
const fd = openSync(log, 'w');
|
|
334
|
+
const child = spawn(process.execPath, [join(here, '..', 'bin', 'motion.mjs'), 'cover', '--out', out, '--round', String(round)], { detached: true, stdio: ['ignore', fd, fd] });
|
|
335
|
+
child.unref();
|
|
336
|
+
console.log(`cover render detached (pid ${child.pid}); poll ${join(out, 'run-state.json')} (finished: true); log ${log}`);
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
@@ -177,7 +177,7 @@ export function encodePreview({ PNG, sourceFrames, masterFps, durationSec, workD
|
|
|
177
177
|
}
|
|
178
178
|
const ok = result.status === 0 && existsSync(output);
|
|
179
179
|
const bytes = ok ? statSync(output).size : null;
|
|
180
|
-
attempts.push({ width, fps, encoder, ok, bytes, error: ok ? null : (result.stderr || result.error?.message || '').split(
|
|
180
|
+
attempts.push({ width, fps, encoder, ok, bytes, error: ok ? null : (result.stderr || result.error?.message || '').split(/\r?\n/)[0] });
|
|
181
181
|
if (!ok) continue;
|
|
182
182
|
rmSync(dir, { recursive: true, force: true });
|
|
183
183
|
if (bytes <= OUTPUT.previewMaxBytes || long === OUTPUT.previewLadder.at(-1)[0]) {
|