@litfamily/litopencode 1.0.28 → 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 (156) hide show
  1. package/CHANGELOG.md +28 -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/deliverable-hedge-guard.js +21 -9
  38. package/dist/features.d.ts +27 -1
  39. package/dist/features.js +40 -8
  40. package/dist/host/server.d.ts +7 -0
  41. package/dist/host/server.js +12 -0
  42. package/dist/host/tui.d.ts +7 -0
  43. package/dist/host/tui.js +12 -0
  44. package/dist/server.js +11 -1
  45. package/dist/skill-renames.d.ts +1 -1
  46. package/dist/skills.d.ts +9 -2
  47. package/dist/skills.js +22 -8
  48. package/dist/tui/mission-source-v2.d.ts +51 -0
  49. package/dist/tui/mission-source-v2.js +52 -0
  50. package/dist/v2/agents.d.ts +105 -0
  51. package/dist/v2/agents.js +185 -0
  52. package/dist/v2/commands.d.ts +44 -0
  53. package/dist/v2/commands.js +65 -0
  54. package/dist/v2/context.d.ts +67 -0
  55. package/dist/v2/context.js +17 -0
  56. package/dist/v2/events.d.ts +55 -0
  57. package/dist/v2/events.js +193 -0
  58. package/dist/v2/hooks.d.ts +39 -0
  59. package/dist/v2/hooks.js +61 -0
  60. package/dist/v2/server.d.ts +2 -0
  61. package/dist/v2/server.js +68 -0
  62. package/dist/v2/session-hooks.d.ts +64 -0
  63. package/dist/v2/session-hooks.js +230 -0
  64. package/dist/v2/tools.d.ts +26 -0
  65. package/dist/v2/tools.js +49 -0
  66. package/dist/v2/tui.d.ts +63 -0
  67. package/dist/v2/tui.js +259 -0
  68. package/docs/assets/cover-motion-still.webp +0 -0
  69. package/docs/assets/cover-motion.webp +0 -0
  70. package/docs/assets/cover.webp +0 -0
  71. package/docs/assets/readme/badge-version.svg +1 -1
  72. package/docs/assets/readme/litfamily-machines.png +0 -0
  73. package/docs/migration.md +4 -3
  74. package/docs/reference-Ko-KR.md +109 -7
  75. package/docs/reference.md +122 -9
  76. package/package.json +10 -5
  77. package/rules/bundled-rules/lit-humanizer.md +2 -0
  78. package/skills/deep-interview/SKILL.md +1 -1
  79. package/skills/lit-brainstorm/SKILL.md +299 -0
  80. package/skills/lit-brainstorm/references/handoff-briefs.md +72 -0
  81. package/skills/lit-brainstorm/references/lenses.md +106 -0
  82. package/skills/lit-brainstorm/references/scoring-and-convergence.md +93 -0
  83. package/skills/lit-brainstorm/references/session-notes-and-lanes.md +122 -0
  84. package/skills/lit-docx/SKILL.md +1 -1
  85. package/skills/lit-docx/run.py +42 -4
  86. package/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -0
  87. package/skills/lit-docx/templates/latex/acs/template.tex +2 -0
  88. package/skills/lit-docx/templates/latex/elsevier/template.tex +2 -0
  89. package/skills/lit-docx/templates/latex/ieee/template.tex +2 -0
  90. package/skills/lit-docx/templates/latex/korean-generic/template.tex +2 -0
  91. package/skills/lit-docx/templates/latex/nature/template.tex +2 -0
  92. package/skills/lit-humanizer/SKILL.md +4 -0
  93. package/skills/lit-humanizer/scripts/core.mjs +81 -1
  94. package/skills/lit-humanizer/scripts/detect.mjs +4 -3
  95. package/skills/lit-humanizer/scripts/guard-scan.mjs +12 -6
  96. package/skills/lit-pptx/SKILL.md +1 -1
  97. package/skills/lit-pptx/command.mjs +94 -0
  98. package/skills/lit-pptx/run.mjs +16 -6
  99. package/skills/lit-pptx/scripts/compile-deck.js +2 -2
  100. package/skills/lit-typographic-motion/SKILL.md +5 -2
  101. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +3 -0
  102. package/skills/lit-typographic-motion/engine/browser.mjs +3 -9
  103. package/skills/lit-typographic-motion/engine/command.mjs +94 -0
  104. package/skills/lit-typographic-motion/engine/constants.mjs +29 -1
  105. package/skills/lit-typographic-motion/engine/cover/assets.mjs +91 -0
  106. package/skills/lit-typographic-motion/engine/cover/capture.mjs +173 -0
  107. package/skills/lit-typographic-motion/engine/cover/decode.mjs +173 -0
  108. package/skills/lit-typographic-motion/engine/cover/frame-worker.mjs +19 -0
  109. package/skills/lit-typographic-motion/engine/cover/gate.mjs +261 -0
  110. package/skills/lit-typographic-motion/engine/cover/image.mjs +138 -0
  111. package/skills/lit-typographic-motion/engine/cover/render.mjs +323 -0
  112. package/skills/lit-typographic-motion/engine/cover/sheet.mjs +65 -0
  113. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +110 -0
  114. package/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  115. package/skills/lit-typographic-motion/engine/look.mjs +113 -10
  116. package/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  117. package/skills/lit-typographic-motion/engine/stage/capture.mjs +26 -18
  118. package/skills/lit-typographic-motion/engine/stage/cover-init.js +38 -0
  119. package/skills/lit-typographic-motion/engine/stage/loop-kit.js +102 -0
  120. package/skills/lit-typographic-motion/engine/stage/render.mjs +4 -4
  121. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  122. package/skills/lit-typographic-motion/fonts/JetBrainsMono-OFL.txt +93 -0
  123. package/skills/lit-typographic-motion/fonts/JetBrainsMono-wght-latin.woff2 +0 -0
  124. package/skills/lit-typographic-motion/fonts/pins.json +3 -1
  125. package/skills/lit-typographic-motion/gate.mjs +34 -1
  126. package/skills/lit-typographic-motion/probe.mjs +8 -3
  127. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +16 -0
  128. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +35 -0
  129. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +16 -0
  130. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +29 -0
  131. package/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +17 -0
  132. package/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  133. package/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +17 -0
  134. package/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +35 -0
  135. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +62 -0
  136. package/skills/lit-typographic-motion/recipes/orbit-loop/index.html +100 -0
  137. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +95 -0
  138. package/skills/lit-typographic-motion/recipes/painted-light/index.html +232 -0
  139. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +62 -0
  140. package/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  141. package/skills/lit-typographic-motion/references/cover.md +423 -0
  142. package/skills/lit-typographic-motion/references/craft-loop.md +2 -0
  143. package/skills/lit-typographic-motion/references/runtime.md +17 -2
  144. package/skills/lit-typographic-motion/references/stage.md +3 -0
  145. package/skills/lit-typographic-motion/references/treatment.md +4 -0
  146. package/skills/lit-typographic-motion/render.mjs +30 -6
  147. package/skills/lit-typographic-motion/runtime.mjs +12 -13
  148. package/skills/{durable-litgoal → litgoal}/SKILL.md +31 -12
  149. package/skills/managed-skill-manifest.json +175 -31
  150. package/skills/review-work/SKILL.md +27 -18
  151. package/skills/skill-rename-aliases.json +2 -1
  152. package/tools/check-pack-payload.mjs +4 -1
  153. package/tools/payload-substance-allowlist.json +1 -1
  154. package/tools/payload-substance-parity.json +6 -4
  155. package/tools/qa-cover-recipes.mjs +167 -0
  156. package/tools/version-manifests.json +6 -0
@@ -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
+ });
@@ -0,0 +1,261 @@
1
+ // The cover gate, measured on the DECODED cover (engine/cover/decode.mjs), never only on the frames
2
+ // that went into the encoder: G0 determinism, G1 size, length and loop, G2 motion energy, G3 holds
3
+ // and spikes, G4 the seam, G5 title contrast through the mask pass, G6 title cap height at README
4
+ // width. Every motion number is taken on a fixed time grid, because the encoder merges identical
5
+ // frames into one long frame and a per-frame count would make a slide film look busy. FLOOR fails
6
+ // the cover; TARGET is what cover mode delivers, and `--strict` (every cover render) fails on it too,
7
+ // 2 MiB included. Thresholds live in engine/constants.mjs (COVER).
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { COVER } from "../constants.mjs";
12
+ import { decodePng } from "../png.mjs";
13
+ import { decodeCover } from "./decode.mjs";
14
+ import { lumaGrid, meanLumaStep, rgbaOf } from "./image.mjs";
15
+
16
+ const sorted = (list) => [...list].sort((a, b) => a - b);
17
+ const median = (list) => { const s = sorted(list), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
18
+ const quantile = (list, q) => { const s = sorted(list); return s[Math.min(s.length - 1, Math.max(0, Math.round(q * (s.length - 1))))]; };
19
+
20
+ /** A PNG on disk as { width, height, rgba }. */
21
+ export function readRgba(file) {
22
+ const image = decodePng(readFileSync(file));
23
+ return { width: image.width, height: image.height, rgba: rgbaOf(image) };
24
+ }
25
+ /** The sorted `<prefix>NNNN.png` files of a capture directory. */
26
+ export function framesIn(dir, prefix = "f") {
27
+ return existsSync(dir) ? readdirSync(dir).filter((name) => name.startsWith(prefix) && name.endsWith(".png")).sort().map((name) => path.join(dir, name)) : [];
28
+ }
29
+ /** SHA-256 of a frame's decoded RGBA, the hash every determinism check compares (never PNG bytes). */
30
+ export const rgbaSha = (rgba) => createHash("sha256").update(rgba).digest("hex");
31
+
32
+ /**
33
+ * Load a cover for the gate: an encoded .webp or .gif, or a directory of PNG frames shown at `fps`.
34
+ * `keep(index, durationMs)` is asked once per decoded frame, in order, whether to keep that frame
35
+ * whole (RGBA) for the stills and the frame-0 art check. Returns { format, width, height, loop,
36
+ * bytes, durations, lumas, kept }.
37
+ */
38
+ export function loadCover(input, { fps = 16, keep = () => false, env = process.env } = {}) {
39
+ const lumas = [];
40
+ const kept = new Map();
41
+ if (statSync(input).isDirectory()) {
42
+ const files = framesIn(input);
43
+ let width = 0, height = 0;
44
+ files.forEach((file, i) => {
45
+ const image = readRgba(file);
46
+ ({ width, height } = image);
47
+ lumas.push(lumaGrid(image.rgba, width, height).luma);
48
+ if (keep(i, 1000 / fps)) kept.set(i, image.rgba);
49
+ });
50
+ return { format: "png", width, height, loop: 0, bytes: null, durations: files.map(() => 1000 / fps), lumas, kept };
51
+ }
52
+ const info = decodeCover(input, {
53
+ env,
54
+ onFrame: (i, rgba, ms, width, height) => {
55
+ lumas.push(lumaGrid(rgba, width, height).luma);
56
+ if (keep(i, ms > 0 ? ms : 100)) kept.set(i, Buffer.from(rgba));
57
+ }
58
+ });
59
+ return { ...info, durations: info.durations.map((ms) => (ms > 0 ? ms : 100)), lumas, kept };
60
+ }
61
+
62
+ /** The frame on show at each grid time 0, step, 2 step, ... over the whole loop. */
63
+ export function onGrid(frames, durations, step) {
64
+ const total = durations.reduce((a, b) => a + b, 0);
65
+ const out = [];
66
+ let index = 0, ends = durations[0];
67
+ for (let ms = 0; ms < total - 1e-6; ms += step) {
68
+ while (ms >= ends && index < frames.length - 1) ends += durations[++index];
69
+ out.push(frames[index]);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Motion on the grid. The loop is a ring: the step from the last sample back to the first is the seam. */
75
+ export function motionMetrics({ lumas, durations }) {
76
+ const step = Math.max(COVER.gridMs, Math.min(...durations));
77
+ const grid = onGrid(lumas, durations, step);
78
+ const n = grid.length;
79
+ const steps = [];
80
+ for (let i = 1; i < n; i++) steps.push(meanLumaStep(grid[i], grid[i - 1]));
81
+ const seam = meanLumaStep(grid[0], grid[n - 1]);
82
+ const ring = [...steps, seam];
83
+ const medianD = median(ring);
84
+ const still = ring.map((d) => d < COVER.stillEps);
85
+ let longest = 0;
86
+ if (still.every(Boolean)) longest = n * step;
87
+ else {
88
+ // Walk the ring once from a moving step, so a hold that wraps through the seam counts whole.
89
+ const from = still.indexOf(false);
90
+ let run = 0;
91
+ for (let k = 1; k <= n; k++) {
92
+ run = still[(from + k) % n] ? run + step : 0;
93
+ longest = Math.max(longest, run);
94
+ }
95
+ }
96
+ // On a coarse grid (merged frames) the last sample reaches past the loop's end; a hold is never longer than the loop.
97
+ longest = Math.min(longest, durations.reduce((a, b) => a + b, 0));
98
+ const neighbours = n >= 4 ? (ring[n - 3] + ring[n - 2] + ring[0] + ring[1]) / 4 : medianD;
99
+ return {
100
+ gridFrames: n, gridMs: step, durationSec: (n * step) / 1000, sourceDurationSec: durations.reduce((a, b) => a + b, 0) / 1000,
101
+ medianD, p10D: quantile(ring, 0.1), p90D: quantile(ring, 0.9), maxD: Math.max(...ring),
102
+ movingFrac: ring.filter((d) => d >= COVER.stillEps).length / ring.length,
103
+ longestStillSec: longest / 1000, spike: Math.max(...ring) / Math.max(medianD, COVER.stillEps),
104
+ seam, seamLocal: seam / Math.max(COVER.stillEps, neighbours), steps: ring
105
+ };
106
+ }
107
+
108
+ const linear = (c) => { const v = c / 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; };
109
+ const luminance = (rgba, p) => 0.2126 * linear(rgba[p]) + 0.7152 * linear(rgba[p + 1]) + 0.0722 * linear(rgba[p + 2]);
110
+ /** A [x, y, w, h] box of fractions as whole pixels of a width x height frame. */
111
+ export const boxPixels = (box, width, height) => box.map((v, k) => Math.round(v * (k % 2 ? height : width)));
112
+
113
+ /**
114
+ * G5 on every frame: the median title pixel (white in the mask) against the 90th-percentile
115
+ * background pixel of the title box, the background being box pixels at least 3 px from any title
116
+ * pixel. Without one mask per frame the gate fails and says what is missing.
117
+ */
118
+ export function titleContrast({ framesDir, masksDir, box }) {
119
+ const frames = framesIn(framesDir, "f"), masks = framesIn(masksDir, "m");
120
+ if (!frames.length || masks.length !== frames.length) return { measured: false, reason: `one mask per frame is needed (${frames.length} frames, ${masks.length} masks)` };
121
+ const ratios = [];
122
+ for (let i = 0; i < frames.length; i++) {
123
+ const frame = readRgba(frames[i]), mask = readRgba(masks[i]);
124
+ const { width, height } = frame;
125
+ if (mask.width !== width || mask.height !== height) return { measured: false, reason: `mask ${i} is ${mask.width}x${mask.height}, the frame ${width}x${height}` };
126
+ const [bx, by, bw, bh] = boxPixels(box, width, height);
127
+ const role = new Uint8Array(width * height); // 0 background, 1 halo, 2 title
128
+ for (let p = 0; p < width * height; p++) if (mask.rgba[p * 4] > 127) role[p] = 2;
129
+ for (let y = by; y < by + bh; y++) for (let x = bx; x < bx + bw; x++) {
130
+ if (role[y * width + x] !== 2) continue;
131
+ for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
132
+ const yy = y + dy, xx = x + dx;
133
+ if (yy >= 0 && yy < height && xx >= 0 && xx < width && role[yy * width + xx] === 0) role[yy * width + xx] = 1;
134
+ }
135
+ }
136
+ const title = [], ground = [];
137
+ for (let y = by; y < by + bh; y++) for (let x = bx; x < bx + bw; x++) {
138
+ const p = y * width + x;
139
+ if (role[p] === 2) title.push(luminance(frame.rgba, p * 4));
140
+ else if (role[p] === 0) ground.push(luminance(frame.rgba, p * 4));
141
+ }
142
+ if (!title.length || !ground.length) return { measured: false, reason: `frame ${i}: the title box holds ${title.length} title and ${ground.length} background pixels` };
143
+ const t = median(title);
144
+ const g = t > median(ground) ? quantile(ground, 0.9) : quantile(ground, 0.1);
145
+ ratios.push((Math.max(t, g) + 0.05) / (Math.min(t, g) + 0.05));
146
+ }
147
+ return { measured: true, min: Math.min(...ratios), median: median(ratios), frames: ratios.length };
148
+ }
149
+
150
+ /**
151
+ * G6 on up to 12 mask frames spread over the loop: the white pixels in the title box are split into
152
+ * connected glyphs; dots and accents (under 1 % of the largest glyph's area or 30 % of the tallest
153
+ * glyph's height) are left out; the median height of the rest is the cap height. The smallest
154
+ * sample, scaled from the cover width to `displayWidth`, must reach COVER.minCapPx. A title painted
155
+ * into declared base art is `exempt`: still measured, reported as INFO. G6 stands beside G5.
156
+ */
157
+ export function capHeight({ masksDir, box, displayWidth = COVER.displayWidth, exempt = null, samples = 12 }) {
158
+ const masks = framesIn(masksDir, "m");
159
+ if (!masks.length) return { pass: false, reason: "no mask frames to measure the title on" };
160
+ const count = Math.min(samples, masks.length);
161
+ const picks = [...new Set(Array.from({ length: count }, (_, k) => Math.floor((k * masks.length) / count)))];
162
+ let cap = Infinity, glyphs = 0, coverWidth = 0;
163
+ for (const i of picks) {
164
+ const mask = readRgba(masks[i]);
165
+ coverWidth = mask.width;
166
+ const [bx, by, bw, bh] = boxPixels(box, mask.width, mask.height);
167
+ const seen = new Uint8Array(bw * bh);
168
+ for (let y = 0; y < bh; y++) for (let x = 0; x < bw; x++) seen[y * bw + x] = mask.rgba[((by + y) * mask.width + bx + x) * 4] > 127 ? 1 : 0;
169
+ const parts = [];
170
+ for (let start = 0; start < seen.length; start++) {
171
+ if (seen[start] !== 1) continue;
172
+ const stack = [start];
173
+ seen[start] = 2;
174
+ let area = 0, top = bh, bottom = -1;
175
+ while (stack.length) {
176
+ const q = stack.pop(), y = Math.floor(q / bw), x = q % bw;
177
+ area += 1; top = Math.min(top, y); bottom = Math.max(bottom, y);
178
+ for (const next of [x > 0 ? q - 1 : -1, x < bw - 1 ? q + 1 : -1, y > 0 ? q - bw : -1, y < bh - 1 ? q + bw : -1]) {
179
+ if (next >= 0 && seen[next] === 1) { seen[next] = 2; stack.push(next); }
180
+ }
181
+ }
182
+ parts.push({ area, height: bottom - top + 1 });
183
+ }
184
+ if (!parts.length) return { pass: false, reason: `mask ${i} has no title pixels in the title box` };
185
+ const largest = Math.max(...parts.map((p) => p.area)), tallest = Math.max(...parts.map((p) => p.height));
186
+ const letters = parts.filter((p) => p.area >= 0.01 * largest && p.height >= 0.3 * tallest);
187
+ glyphs = Math.max(glyphs, letters.length);
188
+ cap = Math.min(cap, median(letters.map((p) => p.height)));
189
+ }
190
+ const capPx = (cap * displayWidth) / coverWidth;
191
+ const measured = { capPx: Number(capPx.toFixed(1)), coverPx: cap, displayWidth, glyphs, sampled: picks.length };
192
+ if (exempt) return { pass: null, ...measured, reason: exempt };
193
+ return { pass: capPx >= COVER.minCapPx, ...measured };
194
+ }
195
+
196
+ /** G0: two captures in fresh browsers, compared frame by frame on decoded RGBA. */
197
+ export function determinism(a, b) {
198
+ const first = framesIn(a, "f"), second = framesIn(b, "f");
199
+ const count = Math.min(first.length, second.length);
200
+ let identical = 0, firstDiffering = null;
201
+ for (let i = 0; i < count; i++) {
202
+ if (rgbaSha(readRgba(first[i]).rgba) === rgbaSha(readRgba(second[i]).rgba)) identical += 1;
203
+ else firstDiffering ??= i;
204
+ }
205
+ return { compared: count, identical, countsMatch: first.length === second.length, firstDiffering };
206
+ }
207
+
208
+ /**
209
+ * Gate one cover. `frames` and `replay` (two capture directories) give G0; `masks` and `title` give
210
+ * G5; `glyph` adds G6. Returns the metrics, `gates`, `pass` (FLOOR) and `target` (TARGET, which
211
+ * `--strict` and every cover render need), plus the kept frames.
212
+ */
213
+ export function coverGate(input, { frames = null, replay = null, masks = null, title = null, glyph = null, fps = 16, keep, env = process.env } = {}) {
214
+ const source = loadCover(input, { fps, keep, env });
215
+ const m = motionMetrics(source);
216
+ const gates = {};
217
+ if (frames && replay) {
218
+ const det = determinism(frames, replay);
219
+ gates.G0 = { pass: det.compared > 0 && det.countsMatch && det.identical === det.compared, ...det };
220
+ }
221
+ if (source.format !== "png") {
222
+ gates.G1 = {
223
+ pass: source.bytes <= COVER.maxBytes && m.sourceDurationSec >= COVER.minSec && m.sourceDurationSec <= COVER.maxSec && source.loop === 0,
224
+ target: source.bytes <= COVER.targetBytes,
225
+ bytes: source.bytes, durationSec: m.sourceDurationSec, loop: source.loop, size: `${source.width}x${source.height}`
226
+ };
227
+ }
228
+ const F = COVER.floor, T = COVER.target;
229
+ gates.G2 = { pass: m.medianD >= F.medianD && m.movingFrac >= F.movingFrac, target: m.medianD >= T.medianD && m.movingFrac >= T.movingFrac, medianD: m.medianD, movingFrac: m.movingFrac };
230
+ gates.G3 = { pass: m.longestStillSec <= F.stillSec, target: m.longestStillSec <= T.stillSec && m.spike <= T.spike, longestStillSec: m.longestStillSec, spike: m.spike };
231
+ gates.G4 = { pass: m.seamLocal <= F.seamLocal, target: m.seamLocal >= T.seamLocalMin && m.seamLocal <= T.seamLocal, seam: m.seam, seamLocal: m.seamLocal };
232
+ if (title && frames) {
233
+ const c = titleContrast({ framesDir: frames, masksDir: masks, box: title });
234
+ gates.G5 = c.measured ? { pass: c.min >= COVER.minContrast, min: c.min, median: c.median, frames: c.frames } : { pass: false, reason: c.reason };
235
+ }
236
+ if (title && masks && glyph) gates.G6 = capHeight({ masksDir: masks, box: title, displayWidth: glyph.displayWidth ?? COVER.displayWidth, exempt: glyph.exempt ?? null });
237
+ return { input, ...source, lumas: undefined, sourceFrames: source.lumas.length, ...m, gates, ...verdicts(gates) };
238
+ }
239
+
240
+ /** FLOOR is every gate's pass; TARGET also needs every gate's target. INFO rows (pass null) count for neither. */
241
+ export function verdicts(gates) {
242
+ const pass = Object.values(gates).every((g) => g.pass !== false);
243
+ return { pass, target: pass && Object.values(gates).every((g) => g.target !== false) };
244
+ }
245
+
246
+ const shown = (v) => (typeof v === "number" && !Number.isInteger(v) ? Number(v.toPrecision(4)) : v);
247
+
248
+ /** The report: the verdict line (the done check reads it), the file line, one line per gate. */
249
+ export function formatCoverReport(result, { strict = false } = {}) {
250
+ const verdict = result.pass ? (result.target ? "PASS+TARGET" : "PASS(floor)") : "FAIL";
251
+ const lines = [
252
+ `COVER gate${strict ? " --strict" : ""}: ${verdict}`,
253
+ ` ${result.width}x${result.height} ${result.format} frames=${result.sourceFrames} dur=${shown(result.sourceDurationSec)}s bytes=${result.bytes ?? "-"} loop=${result.loop} grid=${result.gridMs}ms`
254
+ ];
255
+ for (const [name, gate] of Object.entries(result.gates)) {
256
+ const { pass, target, ...detail } = gate;
257
+ const word = pass === null ? "INFO" : pass ? (target === false ? "pass(floor)" : "pass") : "FAIL";
258
+ lines.push(` ${name} ${word} ${Object.entries(detail).map(([k, v]) => `${k}=${shown(v)}`).join(" ")}`.trimEnd());
259
+ }
260
+ return `${lines.join("\n")}\n`;
261
+ }