@litfamily/litopencode 1.0.29 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README-Ko-KR.md +7 -7
  3. package/README.md +7 -7
  4. package/dist/activation-primary-prompts.js +15 -6
  5. package/dist/activation-prompt-utils.d.ts +2 -2
  6. package/dist/activation-prompt-utils.js +9 -3
  7. package/dist/activation-reentry.d.ts +29 -0
  8. package/dist/activation-reentry.js +71 -0
  9. package/dist/activation-routing.d.ts +1 -1
  10. package/dist/activation-routing.js +40 -7
  11. package/dist/activation-workflow-prompts.d.ts +2 -0
  12. package/dist/activation-workflow-prompts.js +32 -7
  13. package/dist/activation.d.ts +6 -1
  14. package/dist/activation.js +34 -7
  15. package/dist/cli/args.js +14 -0
  16. package/dist/cli/auto-update.js +2 -4
  17. package/dist/cli/command-plan.d.ts +40 -0
  18. package/dist/cli/command-plan.js +62 -0
  19. package/dist/cli/doctor.d.ts +4 -1
  20. package/dist/cli/doctor.js +65 -4
  21. package/dist/cli/host-detect.d.ts +58 -0
  22. package/dist/cli/host-detect.js +199 -0
  23. package/dist/cli/install-report.js +3 -1
  24. package/dist/cli/install.d.ts +9 -1
  25. package/dist/cli/install.js +134 -31
  26. package/dist/cli/managed-skill-assets.d.ts +1 -1
  27. package/dist/cli/managed-skill-assets.js +5 -3
  28. package/dist/cli/office-dependencies.d.ts +2 -0
  29. package/dist/cli/office-dependencies.js +7 -3
  30. package/dist/cli/scientific-visualization-dependencies.js +1 -1
  31. package/dist/cli/tui-config.d.ts +27 -2
  32. package/dist/cli/tui-config.js +77 -2
  33. package/dist/cli/types.d.ts +17 -0
  34. package/dist/cli.js +9 -6
  35. package/dist/commands.d.ts +10 -1
  36. package/dist/commands.js +17 -3
  37. package/dist/features.d.ts +27 -1
  38. package/dist/features.js +40 -8
  39. package/dist/host/server.d.ts +7 -0
  40. package/dist/host/server.js +12 -0
  41. package/dist/host/tui.d.ts +7 -0
  42. package/dist/host/tui.js +12 -0
  43. package/dist/server.js +9 -0
  44. package/dist/skill-renames.d.ts +1 -1
  45. package/dist/skills.d.ts +9 -2
  46. package/dist/skills.js +22 -8
  47. package/dist/tui/mission-source-v2.d.ts +51 -0
  48. package/dist/tui/mission-source-v2.js +52 -0
  49. package/dist/v2/agents.d.ts +105 -0
  50. package/dist/v2/agents.js +185 -0
  51. package/dist/v2/commands.d.ts +44 -0
  52. package/dist/v2/commands.js +65 -0
  53. package/dist/v2/context.d.ts +67 -0
  54. package/dist/v2/context.js +17 -0
  55. package/dist/v2/events.d.ts +55 -0
  56. package/dist/v2/events.js +193 -0
  57. package/dist/v2/hooks.d.ts +39 -0
  58. package/dist/v2/hooks.js +61 -0
  59. package/dist/v2/server.d.ts +2 -0
  60. package/dist/v2/server.js +68 -0
  61. package/dist/v2/session-hooks.d.ts +64 -0
  62. package/dist/v2/session-hooks.js +230 -0
  63. package/dist/v2/tools.d.ts +26 -0
  64. package/dist/v2/tools.js +49 -0
  65. package/dist/v2/tui.d.ts +63 -0
  66. package/dist/v2/tui.js +259 -0
  67. package/docs/assets/cover-motion-still.webp +0 -0
  68. package/docs/assets/cover-motion.webp +0 -0
  69. package/docs/assets/cover.webp +0 -0
  70. package/docs/assets/readme/badge-version.svg +1 -1
  71. package/docs/assets/readme/litfamily-machines.png +0 -0
  72. package/docs/migration.md +4 -3
  73. package/docs/reference-Ko-KR.md +109 -7
  74. package/docs/reference.md +122 -9
  75. package/package.json +10 -5
  76. package/skills/deep-interview/SKILL.md +1 -1
  77. package/skills/lit-brainstorm/SKILL.md +299 -0
  78. package/skills/lit-brainstorm/references/handoff-briefs.md +72 -0
  79. package/skills/lit-brainstorm/references/lenses.md +106 -0
  80. package/skills/lit-brainstorm/references/scoring-and-convergence.md +93 -0
  81. package/skills/lit-brainstorm/references/session-notes-and-lanes.md +122 -0
  82. package/skills/lit-docx/SKILL.md +1 -1
  83. package/skills/lit-docx/run.py +42 -4
  84. package/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -0
  85. package/skills/lit-docx/templates/latex/acs/template.tex +2 -0
  86. package/skills/lit-docx/templates/latex/elsevier/template.tex +2 -0
  87. package/skills/lit-docx/templates/latex/ieee/template.tex +2 -0
  88. package/skills/lit-docx/templates/latex/korean-generic/template.tex +2 -0
  89. package/skills/lit-docx/templates/latex/nature/template.tex +2 -0
  90. package/skills/lit-humanizer/scripts/detect.mjs +1 -1
  91. package/skills/lit-pptx/SKILL.md +1 -1
  92. package/skills/lit-pptx/command.mjs +94 -0
  93. package/skills/lit-pptx/run.mjs +16 -6
  94. package/skills/lit-pptx/scripts/compile-deck.js +2 -2
  95. package/skills/lit-typographic-motion/SKILL.md +5 -2
  96. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +3 -0
  97. package/skills/lit-typographic-motion/engine/browser.mjs +3 -9
  98. package/skills/lit-typographic-motion/engine/command.mjs +94 -0
  99. package/skills/lit-typographic-motion/engine/constants.mjs +29 -1
  100. package/skills/lit-typographic-motion/engine/cover/assets.mjs +91 -0
  101. package/skills/lit-typographic-motion/engine/cover/capture.mjs +173 -0
  102. package/skills/lit-typographic-motion/engine/cover/decode.mjs +173 -0
  103. package/skills/lit-typographic-motion/engine/cover/frame-worker.mjs +19 -0
  104. package/skills/lit-typographic-motion/engine/cover/gate.mjs +261 -0
  105. package/skills/lit-typographic-motion/engine/cover/image.mjs +138 -0
  106. package/skills/lit-typographic-motion/engine/cover/render.mjs +323 -0
  107. package/skills/lit-typographic-motion/engine/cover/sheet.mjs +65 -0
  108. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +110 -0
  109. package/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  110. package/skills/lit-typographic-motion/engine/look.mjs +113 -10
  111. package/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  112. package/skills/lit-typographic-motion/engine/stage/capture.mjs +26 -18
  113. package/skills/lit-typographic-motion/engine/stage/cover-init.js +38 -0
  114. package/skills/lit-typographic-motion/engine/stage/loop-kit.js +102 -0
  115. package/skills/lit-typographic-motion/engine/stage/render.mjs +4 -4
  116. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  117. package/skills/lit-typographic-motion/fonts/JetBrainsMono-OFL.txt +93 -0
  118. package/skills/lit-typographic-motion/fonts/JetBrainsMono-wght-latin.woff2 +0 -0
  119. package/skills/lit-typographic-motion/fonts/pins.json +3 -1
  120. package/skills/lit-typographic-motion/gate.mjs +34 -1
  121. package/skills/lit-typographic-motion/probe.mjs +8 -3
  122. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +16 -0
  123. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +35 -0
  124. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +16 -0
  125. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +29 -0
  126. package/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +17 -0
  127. package/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  128. package/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +17 -0
  129. package/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +35 -0
  130. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +62 -0
  131. package/skills/lit-typographic-motion/recipes/orbit-loop/index.html +100 -0
  132. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +95 -0
  133. package/skills/lit-typographic-motion/recipes/painted-light/index.html +232 -0
  134. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +62 -0
  135. package/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  136. package/skills/lit-typographic-motion/references/cover.md +423 -0
  137. package/skills/lit-typographic-motion/references/craft-loop.md +2 -0
  138. package/skills/lit-typographic-motion/references/runtime.md +17 -2
  139. package/skills/lit-typographic-motion/references/stage.md +3 -0
  140. package/skills/lit-typographic-motion/references/treatment.md +4 -0
  141. package/skills/lit-typographic-motion/render.mjs +30 -6
  142. package/skills/lit-typographic-motion/runtime.mjs +12 -13
  143. package/skills/{durable-litgoal → litgoal}/SKILL.md +31 -12
  144. package/skills/managed-skill-manifest.json +173 -29
  145. package/skills/review-work/SKILL.md +27 -18
  146. package/skills/skill-rename-aliases.json +2 -1
  147. package/tools/check-pack-payload.mjs +4 -1
  148. package/tools/payload-substance-allowlist.json +1 -1
  149. package/tools/payload-substance-parity.json +6 -4
  150. package/tools/qa-cover-recipes.mjs +167 -0
  151. package/tools/version-manifests.json +6 -0
@@ -0,0 +1,94 @@
1
+ // Finding and starting commands on every platform. Windows keeps npm and most shims as `.cmd` files:
2
+ // Node refuses to spawn one without a shell (CVE-2024-27980, `spawn EINVAL`), a bare name never
3
+ // matches `ffmpeg.exe`, and a child's lines end in CRLF. Each rule here is injectable, so a test
4
+ // proves the Windows answer on any machine.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import path from "node:path";
8
+
9
+ /** Lines of a child's output, whichever line ending it printed. */
10
+ export const splitLines = (text) => String(text ?? "").split(/\r?\n/u);
11
+
12
+ /** The path of a command on PATH. On Windows the PATHEXT extensions are tried and a bare name never matches. */
13
+ export function which(name, env = process.env, platform = process.platform) {
14
+ const windows = platform === "win32";
15
+ const extensions = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((ext) => ext.toLowerCase());
16
+ const names = !windows ? [name]
17
+ : [...(extensions.some((ext) => name.toLowerCase().endsWith(ext)) ? [name] : []), ...extensions.map((ext) => name + ext)];
18
+ for (const dir of (env.PATH ?? env.Path ?? "").split(windows ? ";" : path.delimiter)) {
19
+ if (!dir) continue;
20
+ for (const candidate of names) {
21
+ const file = path.join(dir, candidate);
22
+ if (existsSync(file)) return file;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ const shim = /\.(?:cmd|bat)$/iu;
29
+
30
+ /** One cmd.exe argument in double quotes; what quotes cannot protect is refused, so a value never becomes shell syntax. */
31
+ export function quoteForCmd(value) {
32
+ if (/["%\r\n\0]/u.test(value)) throw new Error("A Windows command argument may not contain a quote, a percent sign or a line break.");
33
+ return `"${value.replace(/(\\+)$/u, "$1$1")}"`;
34
+ }
35
+
36
+ /** { command, args, shell } for spawnSync: a `.cmd` or `.bat` shim runs through a shell with quoted arguments. */
37
+ export function commandPlan(command, args, { platform = process.platform, env = process.env, find = (name) => which(name, env, "win32") } = {}) {
38
+ if (platform !== "win32") return { command, args, shell: false };
39
+ const resolved = command.includes("/") || command.includes("\\") ? command : find(command) ?? command;
40
+ return shim.test(resolved) ? { command: quoteForCmd(resolved), args: args.map(quoteForCmd), shell: true } : { command: resolved, args, shell: false };
41
+ }
42
+
43
+ /** npm through Node and its own CLI script when one is at hand, else through the quoted shim. */
44
+ export function npmPlan(args, { platform = process.platform, env = process.env, execPath = process.execPath, exists = existsSync, find = (name) => which(name, env, "win32") } = {}) {
45
+ if (platform !== "win32") return { command: "npm", args, shell: false };
46
+ if (env.npm_execpath && /\.[cm]?js$/iu.test(env.npm_execpath) && /^node(?:\.exe)?$/iu.test(path.win32.basename(execPath))) {
47
+ return { command: execPath, args: [env.npm_execpath, ...args], shell: false };
48
+ }
49
+ const found = find("npm");
50
+ if (found && shim.test(found)) {
51
+ const directory = path.win32.dirname(found);
52
+ const cli = path.win32.join(directory, "node_modules", "npm", "bin", "npm-cli.js");
53
+ if (exists(cli)) {
54
+ const sibling = path.win32.join(directory, "node.exe");
55
+ return { command: exists(sibling) ? sibling : find("node") ?? "node", args: [cli, ...args], shell: false };
56
+ }
57
+ }
58
+ return commandPlan("npm", args, { platform, env, find });
59
+ }
60
+
61
+ /** Runs `command` once to read its Python version: { ok, command, version } when it starts, else ok false. */
62
+ export function probePython(command, env = process.env) {
63
+ const launch = commandPlan(command, ["-c", "import sys; print('%d.%d.%d' % sys.version_info[:3])"], { env });
64
+ const result = spawnSync(launch.command, launch.args, { encoding: "utf8", env, shell: launch.shell, windowsHide: true, timeout: 15000 });
65
+ if (result.error?.code === "ENOENT") return { ok: false, command, detail: "not found" };
66
+ if (result.error || result.status !== 0) return { ok: false, command, detail: (result.stderr || result.error?.message || "did not start").trim() };
67
+ return { ok: true, command, version: result.stdout.trim() };
68
+ }
69
+
70
+ /**
71
+ * The Python a venv of hash-pinned wheels is built from. The pins stop at a minor version, so a newer
72
+ * python3 (such as 3.15) fails pip on the first package without a wheel. `override` is used alone when
73
+ * set; otherwise the newest python3.<max> down to python3.<min> on PATH, then python3 and python.
74
+ * Returns { ok, command, version } or { ok: false, detail } naming each interpreter it passed over.
75
+ */
76
+ export function supportedPython({ min, max, override, env = process.env, probe = probePython }) {
77
+ const range = `3.${min} to 3.${max}`;
78
+ const check = (command) => {
79
+ const found = probe(command, env);
80
+ if (!found.ok) return found;
81
+ const [major, minor] = found.version.split(".").map(Number);
82
+ if (major !== 3 || minor < min) return { ok: false, command, detail: `${found.version} is older than 3.${min}` };
83
+ if (minor > max) return { ok: false, command, detail: `${found.version} is newer than 3.${max}` };
84
+ return found;
85
+ };
86
+ const candidates = override ? [override] : [...Array.from({ length: max - min + 1 }, (_, index) => `python3.${max - index}`), "python3", "python"];
87
+ const passed = [];
88
+ for (const command of candidates) {
89
+ const found = check(command);
90
+ if (found.ok) return found;
91
+ if (override || found.detail !== "not found") passed.push(`${command}: ${found.detail}`);
92
+ }
93
+ return { ok: false, detail: `no supported Python (${range})${passed.length > 0 ? `; ${passed.join(", ")}` : " on PATH"}` };
94
+ }
@@ -14,7 +14,9 @@ export const EXIT = Object.freeze({
14
14
  STAGE_CONTRACT_ERROR: 17,
15
15
  STAGE_NONDETERMINISTIC: 18,
16
16
  STAGE_NETWORK_REQUEST: 19,
17
- SOUND_INVALID: 20
17
+ SOUND_INVALID: 20,
18
+ BLOCKED_NO_COVER_ENCODER: 21, // cover mode: neither img2webp + dwebp nor gifski on PATH
19
+ BLOCKED_ASSET_MISSING: 22 // cover mode: assets.json lists a file that is not there
18
20
  });
19
21
 
20
22
  export const FRAME = Object.freeze({ width: 1920, height: 1080, fps: 60 });
@@ -175,3 +177,29 @@ export const easeTokens = Object.freeze({
175
177
 
176
178
  export const engineCredit = "mexicat/pdoom-video ca251e3dddda422b364385eb484b5a3593a0990d (MIT)";
177
179
  export const engineCreditLine = "Typographic-motion engine adapted from mexicat/pdoom-video (MIT, Giacomo Magnanini), commit `ca251e3`.";
180
+
181
+ // Cover mode (references/cover.md): the README cover loop and its gate on the decoded file. The
182
+ // thresholds were calibrated on 2026-10-20 against eight earlier painted covers (all pass FLOOR),
183
+ // four slide-like negatives (all fail FLOOR) and three pilot loops (all reach TARGET). A number
184
+ // moves only with a new calibration table, never to let a cover through.
185
+ export const COVER = Object.freeze({
186
+ maxBytes: 3 * 1024 * 1024, // G1 FLOOR
187
+ targetBytes: 2 * 1024 * 1024, // G1 TARGET
188
+ minSec: 6, maxSec: 12,
189
+ gridMs: 50, // metrics run on a grid of max(gridMs, shortest decoded frame)
190
+ stillEps: 0.0005, // mean |dY| per grid step (luma 0..1 at about 300 px wide) below this is still
191
+ floor: Object.freeze({ medianD: 0.0004, movingFrac: 0.45, stillSec: 2.0, seamLocal: 8 }),
192
+ target: Object.freeze({ medianD: 0.0015, movingFrac: 0.95, stillSec: 0.5, spike: 6, seamLocal: 2.0, seamLocalMin: 0.25 }),
193
+ minContrast: 4.5, // G5, WCAG ratio of the title against its own box
194
+ minCapPx: 24, displayWidth: 830, // G6: cap height at a full-width README column
195
+ artSsim: 0.9, artMinShare: 0.1, // frame 0 shows the base art
196
+ seekFrames: 16, seekLevels: 1, // frames re-rendered out of order, and the rasterizer's rounding
197
+ windowPadSec: 0.5, maxDefects: 3,
198
+ captureScale: 2,
199
+ fps: Object.freeze([16, 20, 25]),
200
+ aspects: Object.freeze({
201
+ "2:1": Object.freeze({ page: Object.freeze([1200, 600]), size: Object.freeze([1000, 500]) }),
202
+ "3:2": Object.freeze({ page: Object.freeze([1200, 800]), size: Object.freeze([960, 640]) })
203
+ }),
204
+ encode: Object.freeze({ q: 86, edgeQ: 97, edgeFrames: 15, method: 4 })
205
+ });
@@ -0,0 +1,91 @@
1
+ // The cover's asset list, `<out>/assets.json`. Every raster the cover page can load (base art, logo,
2
+ // wordmark, texture) is listed with its path, role, source (user: supplied; generated: made for this
3
+ // cover and labelled so in the reply) and SHA-256, and the renderer checks the list before the first
4
+ // capture. The art is supplied or generated, never replaced by a drawn stand-in: a listed file that
5
+ // is missing stops the run as BLOCKED_ASSET_MISSING (exit 22), and so does the painted-light recipe
6
+ // with no base art listed. After the render, decoded frame 0 must show the base art (artCheck).
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { COVER, EXIT } from "../constants.mjs";
11
+ import { crop, lanczosResize, ssim } from "./image.mjs";
12
+
13
+ export const ASSET_ROLES = Object.freeze(["base-art", "logo", "wordmark", "texture"]);
14
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
15
+ const stop = (code, name, detail) => Object.assign(new Error(`${name} (${code}): ${detail}`), { exitCode: code });
16
+ const badEntry = (field, detail) => stop(EXIT.BLOCKED_TREATMENT_INVALID, "BLOCKED_TREATMENT_INVALID", `assets.json ${field}: ${detail}`);
17
+ const isBox = (b) => Array.isArray(b) && b.length === 4 && b.every((v) => typeof v === "number" && v >= 0 && v <= 1) && b[2] > 0 && b[3] > 0 && b[0] + b[2] <= 1 + 1e-9 && b[1] + b[3] <= 1 + 1e-9;
18
+ const inside = (inner, outer) => inner[0] >= outer[0] - 1e-9 && inner[1] >= outer[1] - 1e-9 && inner[0] + inner[2] <= outer[0] + outer[2] + 1e-9 && inner[1] + inner[3] <= outer[1] + outer[3] + 1e-9;
19
+
20
+ /**
21
+ * Check `<out>/assets.json` against the rasters the static scan found in the cover folder (paths
22
+ * relative to that folder). Returns the listed assets. Exit 22: a listed file is missing, or the
23
+ * painted-light recipe has no base art. Exit 17: a raster the list does not name, or a file whose
24
+ * hash changed since it was listed. Exit 16: a malformed entry.
25
+ */
26
+ export function checkAssets({ out, treatment, rasters, coverDir = "cover" }) {
27
+ const file = path.join(out, "assets.json");
28
+ let assets = [];
29
+ if (existsSync(file)) {
30
+ let doc;
31
+ try { doc = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw badEntry("file", `not JSON (${error.message})`); }
32
+ if (!Array.isArray(doc?.assets)) throw badEntry("assets", "must be an array of { path, role, source, sha256 }");
33
+ assets = doc.assets.map((entry, i) => {
34
+ const at = `assets[${i}]`;
35
+ if (!entry || typeof entry !== "object") throw badEntry(at, "must be an object");
36
+ if (typeof entry.path !== "string" || !entry.path.startsWith(`${coverDir}/`) || entry.path.split("/").includes("..")) throw badEntry(`${at}.path`, `must be a path inside ${coverDir}/, relative to the output directory`);
37
+ if (!ASSET_ROLES.includes(entry.role)) throw badEntry(`${at}.role`, `must be one of ${ASSET_ROLES.join(", ")}`);
38
+ if (!["user", "generated"].includes(entry.source)) throw badEntry(`${at}.source`, "must be user (supplied) or generated");
39
+ if (!/^[0-9a-f]{64}$/u.test(entry.sha256 ?? "")) throw badEntry(`${at}.sha256`, "must be the file's SHA-256 (64 hex digits)");
40
+ if ("box" in entry && !isBox(entry.box)) throw badEntry(`${at}.box`, "must be [x, y, w, h] as fractions of the cover, inside it");
41
+ if (entry.role === "base-art" && !entry.box) throw badEntry(`${at}.box`, "base art needs the box it fills on the cover");
42
+ if (entry.role === "base-art" && !/\.png$/iu.test(entry.path)) throw badEntry(`${at}.path`, "base art is a PNG, so frame 0 can be compared with it");
43
+ if ("checkBox" in entry) {
44
+ if (!isBox(entry.checkBox) || !entry.box || !inside(entry.checkBox, entry.box)) throw badEntry(`${at}.checkBox`, "must be [x, y, w, h] inside the art's box");
45
+ if (entry.checkBox[2] * entry.checkBox[3] < COVER.artMinShare * entry.box[2] * entry.box[3]) throw badEntry(`${at}.checkBox`, `must cover at least ${COVER.artMinShare * 100} % of the art's box`);
46
+ }
47
+ return { path: entry.path, role: entry.role, source: entry.source, sha256: entry.sha256, box: entry.box ?? null, checkBox: entry.checkBox ?? null, title: entry.title === true };
48
+ });
49
+ for (const asset of assets) {
50
+ const real = path.join(out, asset.path);
51
+ if (!existsSync(real)) throw stop(EXIT.BLOCKED_ASSET_MISSING, "BLOCKED_ASSET_MISSING", `${asset.path} is listed in assets.json and is not there; supply it or generate it, never draw a stand-in`);
52
+ if (sha256(readFileSync(real)) !== asset.sha256) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `${asset.path} no longer matches its sha256 in assets.json; list it again after changing it`);
53
+ }
54
+ }
55
+ if (treatment.recipe === "painted-light" && !assets.some((asset) => asset.role === "base-art")) {
56
+ throw stop(EXIT.BLOCKED_ASSET_MISSING, "BLOCKED_ASSET_MISSING", "the painted-light recipe relights a painting, and assets.json lists no base-art; supply the painting or generate it and list it, never draw a stand-in");
57
+ }
58
+ const listed = new Set(assets.map((asset) => asset.path));
59
+ for (const raster of rasters) {
60
+ const rel = `${coverDir}/${raster.rel.split(path.sep).join("/")}`;
61
+ if (!listed.has(rel)) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `${rel} is a raster the page can load and assets.json does not list it; list it with its role, source and sha256`);
62
+ }
63
+ return assets;
64
+ }
65
+
66
+ /**
67
+ * Frame 0 is the poster and shows the base art: crop the check box (by default the whole art box)
68
+ * out of decoded frame 0 and compare it with the same region of the source scaled into the art box.
69
+ * `art` is { rgba, width, height }; boxes are fractions of the cover.
70
+ */
71
+ export function artCheck({ frame0, width, height, art, box, checkBox = null }) {
72
+ const px = (b) => b.map((v, k) => Math.round(v * (k % 2 ? height : width)));
73
+ const [bx, by, bw, bh] = px(box);
74
+ const [cx, cy, cw, ch] = px(checkBox ?? box);
75
+ const scaled = lanczosResize(art.rgba, art.width, art.height, bw, bh);
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 reply labels a finished cover owes: one per asset the list marks as generated. */
81
+ export function generatedAssetLabels(out) {
82
+ const file = path.join(out, "assets.json");
83
+ if (!existsSync(file)) return [];
84
+ try {
85
+ return (JSON.parse(readFileSync(file, "utf8")).assets ?? [])
86
+ .filter((asset) => asset?.source === "generated" && typeof asset.path === "string")
87
+ .map((asset) => `generated: ${asset.path} (${String(asset.role ?? "asset").replace("-", " ")}) was made for this cover, not supplied; label it as generated in the reply`);
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
@@ -0,0 +1,173 @@
1
+ // One cover browser: the stage path's software rung and stage flags at device scale 2, the cover
2
+ // page served from disk on the stage origin (engine/stage/capture.mjs serveStage, so no socket
3
+ // listens), the loop kit at /lit/loop-kit.js, the product faces plus JetBrains Mono at
4
+ // /lit/fonts.css, and the cover init script before any page script. The renderer SEEKS: it calls
5
+ // window.renderFrame(t, { mask }) for any t in [0, 1) in any order and screenshots the page at twice
6
+ // its CSS size; a worker pool reduces each screenshot to the cover size with Lanczos and writes it.
7
+ // The master pass, the replay pass and the seek-order pass each open their own browser.
8
+ import { readFileSync } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { Worker } from "node:worker_threads";
13
+ import { EXIT } from "../constants.mjs";
14
+ import { serveStage, stageChromeFlags, stageFonts, stageOrigin } from "../stage/capture.mjs";
15
+ import { StageError } from "../stage/scan.mjs";
16
+
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ const stageDir = path.join(here, "..", "stage");
19
+
20
+ /**
21
+ * The stage flags with the device scale raised to the cover's capture scale, and raster kept on the
22
+ * CPU and whole. G0 compares every frame of two fresh browsers, and glyphs rasterized through the
23
+ * software GL (canvas fillText, and DOM text at changing weights) came out one level apart on a
24
+ * handful of pixels between them; CPU raster gives the same pixels every time.
25
+ */
26
+ export function coverChromeFlags(width, height, scale) {
27
+ const flags = stageChromeFlags(width, height).map((flag) => (flag === "--force-device-scale-factor=1" ? `--force-device-scale-factor=${scale}` : flag));
28
+ return [...flags, "--disable-gpu-rasterization", "--disable-partial-raster"];
29
+ }
30
+
31
+ /** The stage faces plus JetBrains Mono, a variable face with a real wght axis (100-800). */
32
+ export function coverFonts(skillRoot, state, pins) {
33
+ const fonts = stageFonts(skillRoot, state, pins);
34
+ const file = path.join(skillRoot, "fonts", "JetBrainsMono-wght-latin.woff2");
35
+ fonts.files.set(path.basename(file), file);
36
+ fonts.css += `@font-face { font-family: "JetBrains Mono"; src: url("/lit/fonts/${path.basename(file)}") format("woff2"); font-weight: 100 800; font-style: normal; font-display: block; }\n`;
37
+ fonts.families.push("JetBrains Mono");
38
+ return fonts;
39
+ }
40
+
41
+ const contractError = (detail) => new StageError(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", detail);
42
+
43
+ /**
44
+ * Wait for window.ready, but stop at the page's first uncaught error: a page that throws while it
45
+ * builds (a stage-contract message such as `PAINTING.glows has no entry for the hero panel N`) never
46
+ * becomes ready, so the render reports that message now instead of after the full wait.
47
+ */
48
+ export async function waitForCoverReady(tab, pageFailed, timeout = 60000) {
49
+ const ready = tab.waitForFunction(() => window.ready === true, null, { timeout, polling: 50 }).then(
50
+ () => undefined,
51
+ () => { throw new Error(`the cover page never set window.ready = true within ${timeout / 1000} s`); }
52
+ );
53
+ ready.catch(() => {});
54
+ const failure = await Promise.race([ready, pageFailed]);
55
+ if (failure !== undefined) throw new Error(`the cover page raised an error before it set window.ready: ${failure}`);
56
+ return undefined;
57
+ }
58
+
59
+ /**
60
+ * Launch a cover browser on `coverDir` and load its index.html. `page` is the CSS size; screenshots
61
+ * are `scale` times that. Returns { frame(t, { mask }), capture(), close(), flags }.
62
+ */
63
+ export async function openCover(chromium, { chromePath, profile, coverDir, page: [width, height], scale, fonts }) {
64
+ const flags = coverChromeFlags(width, height, scale);
65
+ let context;
66
+ try {
67
+ context = await chromium.launchPersistentContext(profile, {
68
+ executablePath: chromePath, headless: true, args: flags, chromiumSandbox: true, timeout: 30000,
69
+ viewport: { width, height }, deviceScaleFactor: scale, serviceWorkers: "block"
70
+ });
71
+ } catch (error) {
72
+ const line = String(error?.message ?? error).split("\n").find((part) => part.trim()) ?? String(error);
73
+ throw Object.assign(new Error(`BLOCKED_NO_CHROME (10): Chrome failed to launch headless: ${line}; fix: check that Chrome can start headless in this sandbox`), { exitCode: EXIT.BLOCKED_NO_CHROME });
74
+ }
75
+ const served = await serveStage(context, { stageDir: coverDir, fonts, scripts: { "/lit/loop-kit.js": readFileSync(path.join(stageDir, "loop-kit.js"), "utf8") } });
76
+ await context.addInitScript({ content: readFileSync(path.join(stageDir, "cover-init.js"), "utf8") });
77
+ const tab = context.pages()[0] ?? await context.newPage();
78
+ const errors = [];
79
+ const pageFailed = new Promise((resolve) => tab.on("pageerror", (error) => {
80
+ errors.push(String(error?.message ?? error).split("\n")[0]);
81
+ resolve(errors[0]);
82
+ }));
83
+ const cdp = await context.newCDPSession(tab);
84
+ await cdp.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: scale, mobile: false, screenWidth: width, screenHeight: height });
85
+ const close = async () => { await context.close().catch(() => {}); };
86
+ // Everything a cover page may not do, named; `at` says when.
87
+ const check = async (at) => {
88
+ if (served.blocked.length) throw new StageError(EXIT.STAGE_NETWORK_REQUEST, "STAGE_NETWORK_REQUEST", `the cover page requested ${served.blocked[0]}; everything is local`);
89
+ if (served.refused.length) throw contractError(`the cover page asked for ${served.refused[0]}, which is outside the cover folder or not a stage file type`);
90
+ const used = await tab.evaluate(() => (window.__litCover ? window.__litCover.used() : [])).catch(() => []);
91
+ if (used.length) throw new StageError(EXIT.STAGE_NONDETERMINISTIC, "STAGE_NONDETERMINISTIC", `the cover page called ${used.join(", ")}${at ? ` ${at}` : ""}; a cover frame is a function of t alone (LitLoop.rng(seed) for layout)`);
92
+ };
93
+ try {
94
+ try { await tab.goto(`${stageOrigin}/index.html`, { waitUntil: "load", timeout: 60000 }); }
95
+ catch (error) { await check("while loading"); throw contractError(`the cover page did not load: ${String(error?.message ?? error).split("\n")[0]}`); }
96
+ try { await waitForCoverReady(tab, pageFailed); }
97
+ catch (error) {
98
+ await check("while loading");
99
+ throw contractError(`${error.message}${served.missing[0] ? `; missing file: ${served.missing[0]}` : ""}`);
100
+ }
101
+ await check("while loading");
102
+ if (await tab.evaluate(() => typeof window.renderFrame) !== "function") throw contractError("the cover page set window.ready but defines no window.renderFrame(t, { mask })");
103
+ } catch (error) {
104
+ await close();
105
+ throw error;
106
+ }
107
+ return {
108
+ flags, errors, missing: served.missing, close,
109
+ async frame(t, { mask = false } = {}) {
110
+ try {
111
+ await tab.evaluate(async ({ time, asMask }) => {
112
+ await window.renderFrame(time, { mask: asMask });
113
+ if (document.fonts) await document.fonts.ready;
114
+ }, { time: t, asMask: mask });
115
+ } catch (error) {
116
+ await check(`at t = ${t.toFixed(4)}`);
117
+ throw contractError(`renderFrame(${t.toFixed(4)}${mask ? ", { mask: true }" : ""}) threw: ${String(error?.message ?? error).split("\n")[0]}`);
118
+ }
119
+ await check(`at t = ${t.toFixed(4)}`);
120
+ },
121
+ async capture() {
122
+ const shot = await cdp.send("Page.captureScreenshot", { format: "png", optimizeForSpeed: true, captureBeyondViewport: false, fromSurface: true, clip: { x: 0, y: 0, width, height, scale: 1 } });
123
+ return Buffer.from(shot.data, "base64");
124
+ }
125
+ };
126
+ }
127
+
128
+ /** Worker threads that reduce screenshots to the cover size, write them, and hash the pixels. */
129
+ export class CoverPool {
130
+ constructor(size = Math.max(2, Math.min(6, os.availableParallelism() - 3))) {
131
+ this.workers = Array.from({ length: size }, () => new Worker(path.join(here, "frame-worker.mjs")));
132
+ this.jobs = new Map();
133
+ this.id = 0;
134
+ this.turn = 0;
135
+ for (const worker of this.workers) {
136
+ worker.on("message", (message) => {
137
+ const job = this.jobs.get(message.id);
138
+ this.jobs.delete(message.id);
139
+ if (message.error) job.reject(contractError(message.error)); else job.resolve(message);
140
+ });
141
+ worker.on("error", (error) => { for (const job of this.jobs.values()) job.reject(error); this.jobs.clear(); });
142
+ }
143
+ }
144
+ reduce(png, size, file) {
145
+ const id = this.id++;
146
+ const bytes = new Uint8Array(png);
147
+ return new Promise((resolve, reject) => {
148
+ this.jobs.set(id, { resolve, reject });
149
+ this.workers[this.turn++ % this.workers.length].postMessage({ id, png: bytes, size, file }, [bytes.buffer]);
150
+ });
151
+ }
152
+ async close() { await Promise.all(this.workers.map((worker) => worker.terminate())); }
153
+ }
154
+
155
+ /**
156
+ * Draw and capture `times` ([index, t] pairs) in the order given, writing `<prefix>NNNN.png` at the
157
+ * cover `size` into `dir`. Returns Map index -> SHA-256 of the reduced frame's RGBA.
158
+ */
159
+ export async function capturePass({ browser, pool, times, dir, prefix = "f", mask = false, size, expected }) {
160
+ const hashes = new Map();
161
+ const pending = [];
162
+ for (const [index, t] of times) {
163
+ await browser.frame(t, { mask });
164
+ const png = await browser.capture();
165
+ pending.push(pool.reduce(png, size, path.join(dir, `${prefix}${String(index).padStart(4, "0")}.png`)).then((result) => {
166
+ if (result.captured !== expected) throw contractError(`the capture is ${result.captured}, not ${expected}; the cover page must not resize its viewport`);
167
+ hashes.set(index, result.sha256);
168
+ }));
169
+ if (pending.length >= 12) await Promise.all(pending.splice(0, 6));
170
+ }
171
+ await Promise.all(pending);
172
+ return hashes;
173
+ }
@@ -0,0 +1,173 @@
1
+ // Decode an encoded cover the way a README reader's browser shows it, frame by frame, with each
2
+ // frame's display time. The gate reads only this: a lossy animation encoder skips pixels that change
3
+ // by less than a few levels, and those skips exist in the file, never in the PNG frames it was fed.
4
+ //
5
+ // Animated WebP: the RIFF container is read here (canvas, loop count, and every ANMF rectangle with
6
+ // its duration, blend and dispose bits); each frame's own bitstream is wrapped as a still WebP and
7
+ // decoded by `dwebp` from libwebp (it ships beside img2webp); the stills are composited on a
8
+ // transparent canvas as libwebp's animation decoder does. GIF (the gifski fallback): ffmpeg decodes
9
+ // the frames and the container gives the delays and the loop count.
10
+ import { spawnSync } from "node:child_process";
11
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { which } from "../browser.mjs";
15
+
16
+ const u24 = (bytes, at) => bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
17
+
18
+ /** The WebP container: { width, height, loop, frames: [{ x, y, w, h, durationMs, blend, dispose, payload }] }. */
19
+ export function readWebpContainer(bytes) {
20
+ if (bytes.length < 20 || bytes.toString("latin1", 0, 4) !== "RIFF" || bytes.toString("latin1", 8, 12) !== "WEBP") throw new Error("not a WebP file");
21
+ const info = { width: 0, height: 0, loop: null, frames: [] };
22
+ const end = Math.min(bytes.length, 8 + bytes.readUInt32LE(4));
23
+ for (let at = 12; at + 8 <= end;) {
24
+ const kind = bytes.toString("latin1", at, at + 4);
25
+ const size = bytes.readUInt32LE(at + 4);
26
+ const body = bytes.subarray(at + 8, at + 8 + size);
27
+ if (kind === "VP8X") {
28
+ info.width = 1 + u24(body, 4);
29
+ info.height = 1 + u24(body, 7);
30
+ } else if (kind === "ANIM") {
31
+ info.loop = body.readUInt16LE(4);
32
+ } else if (kind === "ANMF") {
33
+ info.frames.push({ x: 2 * u24(body, 0), y: 2 * u24(body, 3), w: 1 + u24(body, 6), h: 1 + u24(body, 9), durationMs: u24(body, 12), blend: (body[15] & 2) === 0, dispose: (body[15] & 1) === 1, payload: body.subarray(16) });
34
+ } else if ((kind === "VP8 " || kind === "VP8L") && info.frames.length === 0) {
35
+ info.frames.push({ x: 0, y: 0, w: 0, h: 0, durationMs: 0, blend: false, dispose: false, still: bytes.subarray(at, at + 8 + size + (size & 1)) });
36
+ }
37
+ at += 8 + size + (size & 1);
38
+ }
39
+ if (info.loop === null && info.frames.length === 1) info.loop = 0;
40
+ return info;
41
+ }
42
+
43
+ // One ANMF payload (an optional ALPH chunk, then VP8 or VP8L) as a standalone still WebP.
44
+ function stillWebp(frame) {
45
+ if (frame.still) return withRiffSize(Buffer.concat([Buffer.from("RIFF\0\0\0\0WEBP", "latin1"), frame.still]));
46
+ const alpha = frame.payload.toString("latin1", 0, 4) === "ALPH";
47
+ const parts = [];
48
+ if (alpha) {
49
+ const vp8x = Buffer.alloc(18);
50
+ vp8x.write("VP8X", 0, "latin1");
51
+ vp8x.writeUInt32LE(10, 4);
52
+ vp8x[8] = 0x10;
53
+ vp8x.writeUIntLE(frame.w - 1, 12, 3);
54
+ vp8x.writeUIntLE(frame.h - 1, 15, 3);
55
+ parts.push(vp8x);
56
+ }
57
+ parts.push(frame.payload);
58
+ return withRiffSize(Buffer.concat([Buffer.from("RIFF\0\0\0\0WEBP", "latin1"), ...parts]));
59
+ }
60
+ function withRiffSize(buffer) {
61
+ buffer.writeUInt32LE(buffer.length - 8, 4);
62
+ return buffer;
63
+ }
64
+
65
+ /** A PAM (P7) image as dwebp writes it: { width, height, rgba }. */
66
+ export function readPam(buffer) {
67
+ const end = buffer.indexOf("ENDHDR\n");
68
+ if (buffer.toString("latin1", 0, 2) !== "P7" || end < 0) throw new Error("not a PAM image");
69
+ const header = buffer.toString("latin1", 0, end);
70
+ const field = (name) => Number(header.match(new RegExp(`${name} (\\d+)`, "u"))?.[1]);
71
+ const width = field("WIDTH"), height = field("HEIGHT"), depth = field("DEPTH");
72
+ const data = buffer.subarray(end + 7);
73
+ if (depth === 4) return { width, height, rgba: data };
74
+ const rgba = Buffer.alloc(width * height * 4, 255);
75
+ for (let i = 0; i < width * height; i++) for (let c = 0; c < 3; c++) rgba[i * 4 + c] = data[i * depth + Math.min(c, depth - 1)];
76
+ return { width, height, rgba };
77
+ }
78
+
79
+ // Draw a decoded rectangle onto the canvas: replace, or alpha-blend without premultiplication.
80
+ function composite(canvas, canvasWidth, frame, rgba) {
81
+ for (let y = 0; y < frame.h; y++) {
82
+ for (let x = 0; x < frame.w; x++) {
83
+ const s = (y * frame.w + x) * 4, d = ((frame.y + y) * canvasWidth + frame.x + x) * 4;
84
+ const sa = rgba[s + 3];
85
+ if (!frame.blend || sa === 255) {
86
+ canvas[d] = rgba[s]; canvas[d + 1] = rgba[s + 1]; canvas[d + 2] = rgba[s + 2]; canvas[d + 3] = sa;
87
+ continue;
88
+ }
89
+ if (sa === 0) continue;
90
+ const below = (canvas[d + 3] * (255 - sa)) / 255;
91
+ const alpha = sa + below;
92
+ for (let c = 0; c < 3; c++) canvas[d + c] = Math.round((rgba[s + c] * sa + canvas[d + c] * below) / alpha);
93
+ canvas[d + 3] = Math.round(alpha);
94
+ }
95
+ }
96
+ }
97
+
98
+ function clearRect(canvas, canvasWidth, frame) {
99
+ for (let y = frame.y; y < frame.y + frame.h; y++) canvas.fill(0, (y * canvasWidth + frame.x) * 4, (y * canvasWidth + frame.x + frame.w) * 4);
100
+ }
101
+
102
+ /** The GIF container: per-frame delays in ms, the loop count and the size. */
103
+ export function readGifContainer(bytes) {
104
+ if (bytes.toString("latin1", 0, 3) !== "GIF") throw new Error("not a GIF file");
105
+ const delays = [];
106
+ let loop = null, delay = 100;
107
+ let at = 13 + (bytes[10] & 0x80 ? 3 * 2 ** ((bytes[10] & 7) + 1) : 0);
108
+ const skipBlocks = () => { while (at < bytes.length && bytes[at] !== 0) at += bytes[at] + 1; at += 1; };
109
+ while (at < bytes.length) {
110
+ const marker = bytes[at];
111
+ if (marker === 0x3b) break;
112
+ if (marker === 0x21) {
113
+ const label = bytes[at + 1];
114
+ if (label === 0xf9) delay = 10 * bytes.readUInt16LE(at + 4);
115
+ if (label === 0xff && bytes.toString("latin1", at + 3, at + 14) === "NETSCAPE2.0") loop = bytes.readUInt16LE(at + 16);
116
+ at += 2;
117
+ skipBlocks();
118
+ } else if (marker === 0x2c) {
119
+ const flags = bytes[at + 9];
120
+ at += 10 + (flags & 0x80 ? 3 * 2 ** ((flags & 7) + 1) : 0) + 1;
121
+ skipBlocks();
122
+ delays.push(delay > 0 ? delay : 100);
123
+ delay = 100;
124
+ } else break;
125
+ }
126
+ return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8), loop, delays };
127
+ }
128
+
129
+ /**
130
+ * Decode every frame of `file` (.webp or .gif). `onFrame(index, rgba, durationMs, width, height)`
131
+ * sees the composited canvas, which is reused between calls: copy what you keep. Returns
132
+ * { format, width, height, loop, durations, bytes }.
133
+ */
134
+ export function decodeCover(file, { env = process.env, onFrame = () => {} } = {}) {
135
+ const bytes = readFileSync(file);
136
+ if (file.toLowerCase().endsWith(".gif")) {
137
+ const info = readGifContainer(bytes);
138
+ const ffmpeg = which("ffmpeg", env);
139
+ if (!ffmpeg) throw new Error("ffmpeg is needed to decode a GIF cover");
140
+ const raw = spawnSync(ffmpeg, ["-v", "error", "-i", file, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "rgba", "pipe:1"], { maxBuffer: 2 ** 31 });
141
+ if (raw.status !== 0) throw new Error(`ffmpeg could not decode ${file}: ${String(raw.stderr).trim().split(/\r?\n/u)[0]}`);
142
+ const size = info.width * info.height * 4;
143
+ const count = Math.min(info.delays.length, Math.floor(raw.stdout.length / size));
144
+ for (let i = 0; i < count; i++) onFrame(i, raw.stdout.subarray(i * size, (i + 1) * size), info.delays[i], info.width, info.height);
145
+ return { format: "gif", width: info.width, height: info.height, loop: info.loop, durations: info.delays.slice(0, count), bytes: bytes.length };
146
+ }
147
+ const dwebp = which("dwebp", env);
148
+ if (!dwebp) throw new Error("dwebp not found (libwebp ships it beside img2webp); install libwebp");
149
+ const info = readWebpContainer(bytes);
150
+ if (!info.frames.length) throw new Error(`${file} holds no frame`);
151
+ const work = mkdtempSync(path.join(os.tmpdir(), "ltm-webp-"));
152
+ try {
153
+ let width = info.width, height = info.height, canvas = null, previous = null;
154
+ const durations = [];
155
+ info.frames.forEach((frame, i) => {
156
+ const source = path.join(work, "frame.webp"), target = path.join(work, "frame.pam");
157
+ writeFileSync(source, stillWebp(frame));
158
+ const run = spawnSync(dwebp, [source, "-pam", "-quiet", "-o", target], { encoding: "utf8" });
159
+ if (run.status !== 0) throw new Error(`dwebp failed on frame ${i}: ${(run.stderr || run.error?.message || "").trim().split(/\r?\n/u)[0]}`);
160
+ const decoded = readPam(readFileSync(target));
161
+ if (frame.still) { width = decoded.width; height = decoded.height; frame.w = width; frame.h = height; }
162
+ canvas ??= Buffer.alloc(width * height * 4);
163
+ if (previous?.dispose) clearRect(canvas, width, previous);
164
+ composite(canvas, width, frame, decoded.rgba);
165
+ durations.push(frame.durationMs);
166
+ onFrame(i, canvas, frame.durationMs, width, height);
167
+ previous = frame;
168
+ });
169
+ return { format: "webp", width, height, loop: info.loop, durations, bytes: bytes.length };
170
+ } finally {
171
+ rmSync(work, { recursive: true, force: true });
172
+ }
173
+ }
@@ -0,0 +1,19 @@
1
+ // Worker thread for the cover capture: decode one 2x screenshot, reduce it to the cover size with
2
+ // Lanczos, write it as a PNG (the encoder's input, and what G0, G5, G6 and the seek check read)
3
+ // and return the SHA-256 of the reduced RGBA, so the browser can draw the next frame meanwhile.
4
+ import { createHash } from "node:crypto";
5
+ import { writeFileSync } from "node:fs";
6
+ import { parentPort } from "node:worker_threads";
7
+ import { decodePng, encodePng } from "../png.mjs";
8
+ import { lanczosResize, rgbaOf } from "./image.mjs";
9
+
10
+ parentPort.on("message", ({ id, png, size: [width, height], file }) => {
11
+ try {
12
+ const shot = decodePng(Buffer.from(png));
13
+ const small = lanczosResize(rgbaOf(shot), shot.width, shot.height, width, height);
14
+ writeFileSync(file, encodePng(width, height, small, 4, 1));
15
+ parentPort.postMessage({ id, sha256: createHash("sha256").update(small).digest("hex"), captured: `${shot.width}x${shot.height}` });
16
+ } catch (error) {
17
+ parentPort.postMessage({ id, error: String(error?.message ?? error) });
18
+ }
19
+ });