@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,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
+ }
@@ -0,0 +1,138 @@
1
+ // Pixel helpers for cover mode, on RGBA8 buffers. The 2x capture is reduced to the cover size with a
2
+ // Lanczos-3 filter (sharper than the box filter the film preview uses, so thin light edges survive);
3
+ // the gate measures luma on a box grid about 300 px wide, which is what a thumbnail shows; and the
4
+ // frame-0 art check compares two crops with a tiled SSIM.
5
+
6
+ const LOBES = 3;
7
+ const sinc = (x) => (x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x));
8
+ const lanczos = (x) => (Math.abs(x) < LOBES ? sinc(x) * sinc(x / LOBES) : 0);
9
+
10
+ // One filter row per output pixel: the first source index and the normalised weights. Shrinking
11
+ // widens the kernel by the scale, so every source pixel contributes.
12
+ const tapCache = new Map();
13
+ function taps(source, target) {
14
+ const key = `${source}>${target}`;
15
+ if (tapCache.has(key)) return tapCache.get(key);
16
+ const scale = source / target;
17
+ const stretch = Math.max(1, scale);
18
+ const reach = LOBES * stretch;
19
+ const rows = [];
20
+ for (let i = 0; i < target; i++) {
21
+ const centre = (i + 0.5) * scale - 0.5;
22
+ const first = Math.max(0, Math.ceil(centre - reach));
23
+ const last = Math.min(source - 1, Math.floor(centre + reach));
24
+ const weights = new Float64Array(last - first + 1);
25
+ let total = 0;
26
+ for (let j = first; j <= last; j++) total += weights[j - first] = lanczos((j - centre) / stretch);
27
+ for (let k = 0; k < weights.length; k++) weights[k] /= total;
28
+ rows.push({ first, weights });
29
+ }
30
+ tapCache.set(key, rows);
31
+ return rows;
32
+ }
33
+
34
+ const byte = (v) => (v <= 0 ? 0 : v >= 255 ? 255 : Math.round(v));
35
+
36
+ /** Lanczos-3 resize of an opaque RGBA8 image; the result is opaque (alpha 255). */
37
+ export function lanczosResize(src, sw, sh, dw, dh) {
38
+ if (sw === dw && sh === dh) return Buffer.from(src);
39
+ const across = taps(sw, dw);
40
+ const down = taps(sh, dh);
41
+ const mid = new Float32Array(dw * sh * 3);
42
+ for (let y = 0; y < sh; y++) {
43
+ const row = y * sw * 4;
44
+ for (let x = 0; x < dw; x++) {
45
+ const { first, weights } = across[x];
46
+ let r = 0, g = 0, b = 0;
47
+ for (let k = 0; k < weights.length; k++) {
48
+ const s = row + (first + k) * 4, w = weights[k];
49
+ r += src[s] * w; g += src[s + 1] * w; b += src[s + 2] * w;
50
+ }
51
+ const m = (y * dw + x) * 3;
52
+ mid[m] = r; mid[m + 1] = g; mid[m + 2] = b;
53
+ }
54
+ }
55
+ const out = Buffer.alloc(dw * dh * 4);
56
+ for (let y = 0; y < dh; y++) {
57
+ const { first, weights } = down[y];
58
+ for (let x = 0; x < dw; x++) {
59
+ let r = 0, g = 0, b = 0;
60
+ for (let k = 0; k < weights.length; k++) {
61
+ const m = ((first + k) * dw + x) * 3, w = weights[k];
62
+ r += mid[m] * w; g += mid[m + 1] * w; b += mid[m + 2] * w;
63
+ }
64
+ const o = (y * dw + x) * 4;
65
+ out[o] = byte(r); out[o + 1] = byte(g); out[o + 2] = byte(b); out[o + 3] = 255;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ export const LUMA_WIDTH = 300;
72
+
73
+ /** Rec. 709 luma (0..255) box-averaged to about LUMA_WIDTH wide: { luma, width, height }. */
74
+ export function lumaGrid(rgba, width, height) {
75
+ const step = Math.max(1, Math.round(width / LUMA_WIDTH));
76
+ const w = Math.floor(width / step), h = Math.floor(height / step);
77
+ const luma = new Float32Array(w * h);
78
+ for (let y = 0; y < h * step; y++) {
79
+ const cell = Math.floor(y / step) * w;
80
+ for (let x = 0; x < w * step; x++) {
81
+ const p = (y * width + x) * 4;
82
+ luma[cell + Math.floor(x / step)] += 0.2126 * rgba[p] + 0.7152 * rgba[p + 1] + 0.0722 * rgba[p + 2];
83
+ }
84
+ }
85
+ const area = step * step;
86
+ for (let i = 0; i < luma.length; i++) luma[i] /= area;
87
+ return { luma, width: w, height: h };
88
+ }
89
+
90
+ /** Mean |a - b| over two luma grids, as a share of 255. */
91
+ export function meanLumaStep(a, b) {
92
+ let sum = 0;
93
+ for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]);
94
+ return sum / a.length / 255;
95
+ }
96
+
97
+ /** The [x, y, w, h] rectangle of an RGBA8 frame `width` pixels wide. */
98
+ export function crop(rgba, width, [x, y, w, h]) {
99
+ const out = Buffer.alloc(w * h * 4);
100
+ for (let row = 0; row < h; row++) rgba.copy(out, row * w * 4, ((y + row) * width + x) * 4, ((y + row) * width + x + w) * 4);
101
+ return out;
102
+ }
103
+
104
+ /** Structural similarity of two same-size RGBA8 images on luma: the mean over 8x8 tiles. */
105
+ export function ssim(a, b, width, height) {
106
+ const tile = 8;
107
+ const c1 = (0.01 * 255) ** 2, c2 = (0.03 * 255) ** 2;
108
+ const lum = (img, i) => 0.2126 * img[i * 4] + 0.7152 * img[i * 4 + 1] + 0.0722 * img[i * 4 + 2];
109
+ let total = 0, tiles = 0;
110
+ for (let ty = 0; ty + tile <= height; ty += tile) {
111
+ for (let tx = 0; tx + tile <= width; tx += tile) {
112
+ let sa = 0, sb = 0, saa = 0, sbb = 0, sab = 0;
113
+ for (let y = ty; y < ty + tile; y++) {
114
+ for (let x = tx; x < tx + tile; x++) {
115
+ const i = y * width + x, la = lum(a, i), lb = lum(b, i);
116
+ sa += la; sb += lb; saa += la * la; sbb += lb * lb; sab += la * lb;
117
+ }
118
+ }
119
+ const n = tile * tile;
120
+ const ma = sa / n, mb = sb / n;
121
+ const va = (saa - n * ma * ma) / (n - 1), vb = (sbb - n * mb * mb) / (n - 1), cov = (sab - n * ma * mb) / (n - 1);
122
+ total += ((2 * ma * mb + c1) * (2 * cov + c2)) / ((ma * ma + mb * mb + c1) * (va + vb + c2));
123
+ tiles += 1;
124
+ }
125
+ }
126
+ return tiles ? total / tiles : 0;
127
+ }
128
+
129
+ /** An RGBA8 copy of a decoded PNG ({ width, height, channels, pixels }). */
130
+ export function rgbaOf(image) {
131
+ if (image.channels === 4) return image.pixels;
132
+ const out = Buffer.alloc(image.width * image.height * 4);
133
+ for (let i = 0, j = 0; j < out.length; i += image.channels, j += 4) {
134
+ const grey = image.channels < 3;
135
+ out[j] = image.pixels[i]; out[j + 1] = grey ? image.pixels[i] : image.pixels[i + 1]; out[j + 2] = grey ? image.pixels[i] : image.pixels[i + 2]; out[j + 3] = 255;
136
+ }
137
+ return out;
138
+ }