@litfamily/lithermes 1.0.27 → 1.0.29

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 (94) hide show
  1. package/README.md +10 -9
  2. package/README_Ko-KR.md +10 -9
  3. package/assets/lithermes-plugin/README.md +28 -9
  4. package/assets/lithermes-plugin/__init__.py +7 -3
  5. package/assets/lithermes-plugin/auto_update.py +17 -3
  6. package/assets/lithermes-plugin/core.py +41 -2
  7. package/assets/lithermes-plugin/core_contexts.py +27 -9
  8. package/assets/lithermes-plugin/core_contract.py +7 -2
  9. package/assets/lithermes-plugin/core_review.py +144 -3
  10. package/assets/lithermes-plugin/core_routing.py +10 -0
  11. package/assets/lithermes-plugin/core_workflow_commands.py +3 -2
  12. package/assets/lithermes-plugin/diagnostics.py +5 -3
  13. package/assets/lithermes-plugin/lit_reentry.py +233 -0
  14. package/assets/lithermes-plugin/payload-version.json +182 -50
  15. package/assets/lithermes-plugin/plugin.yaml +1 -1
  16. package/assets/lithermes-plugin/rules/__init__.py +2 -0
  17. package/assets/lithermes-plugin/rules/engine.py +26 -11
  18. package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +2 -1
  19. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +3 -3
  20. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/hostexec.mjs +118 -0
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +6 -5
  22. package/assets/lithermes-plugin/skills/lit-brainstorm/SKILL.md +408 -0
  23. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/doctor.mjs +3 -3
  24. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/export.mjs +5 -5
  25. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/hostexec.mjs +118 -0
  26. package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -4
  27. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/acs/template.tex +3 -0
  28. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/elsevier/template.tex +3 -0
  29. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/ieee/template.tex +3 -0
  30. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/korean-generic/template.tex +3 -0
  31. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/nature/template.tex +3 -0
  32. package/assets/lithermes-plugin/skills/lit-pptx/bin/hostexec.mjs +118 -0
  33. package/assets/lithermes-plugin/skills/lit-pptx/bin/office.mjs +16 -9
  34. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +13 -12
  35. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +19 -6
  36. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +25 -19
  37. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +2 -0
  38. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/browser.mjs +11 -4
  39. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/constants.mjs +29 -0
  40. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-assets.mjs +87 -0
  41. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-capture.mjs +189 -0
  42. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-gate.mjs +263 -0
  43. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-image.mjs +103 -0
  44. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-init.js +36 -0
  45. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-png.mjs +77 -0
  46. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-sheet.mjs +70 -0
  47. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-treatment.mjs +104 -0
  48. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-webp.mjs +112 -0
  49. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-worker.mjs +19 -0
  50. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +338 -0
  51. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  52. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/hostexec.mjs +118 -0
  53. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/look.mjs +103 -7
  54. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/loop-kit.js +80 -0
  55. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  56. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +8 -2
  57. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  58. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +49 -0
  59. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +37 -0
  60. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +49 -0
  61. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +27 -0
  62. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +51 -0
  63. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  64. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +51 -0
  65. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
  66. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +68 -0
  67. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/index.html +112 -0
  68. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/expected.json +96 -0
  69. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/index.html +208 -0
  70. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/expected.json +68 -0
  71. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  72. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/cover.md +458 -0
  73. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/craft-loop.md +4 -1
  74. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/runtime.md +13 -5
  75. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +4 -2
  76. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +6 -0
  77. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/fonts.json +11 -0
  78. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/requirements.lock +72 -0
  79. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +19 -1
  80. package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +16 -3
  81. package/assets/lithermes-plugin/skills/readme-studio/templates/hyperframes/package-lock.json +3 -3
  82. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  83. package/assets/lithermes-plugin/skills/review-work/SKILL.md +38 -16
  84. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  85. package/package.json +2 -1
  86. package/readme-assets/badge-version.svg +1 -1
  87. package/readme-assets/cover-motion-still.webp +0 -0
  88. package/readme-assets/cover-motion.webp +0 -0
  89. package/src/cli.js +2 -2
  90. package/src/lib/check.js +9 -6
  91. package/src/lib/hermesDiscovery.js +2 -2
  92. package/src/lib/hostExec.js +81 -0
  93. package/src/lib/skillPayload.js +1 -1
  94. package/src/lib/updateNotifier.js +14 -4
@@ -0,0 +1,263 @@
1
+ // The cover gate (references/cover.md): G0 determinism, G1 budget and loop, G2 motion energy, G3
2
+ // anti-slide, G4 seam, G5 title contrast, G6 title cap height. It measures the DECODED cover (the
3
+ // animated WebP, or the GIF fallback), never only the frames that went into the encoder. Motion
4
+ // numbers are taken on a fixed time grid, because the encoder merges identical frames into one
5
+ // long frame and counting frames would make a slide film look busy; the loop is a ring, so the
6
+ // seam is one more step. FLOOR fails a cover, TARGET is the cover-mode goal and the strict verdict.
7
+ import { createHash } from 'node:crypto';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { COVER } from './constants.mjs';
12
+ import { lumaGrid } from './cover-image.mjs';
13
+ import { decodePng } from './cover-png.mjs';
14
+ import { decodeWebp, which } from './cover-webp.mjs';
15
+
16
+ const sorted = (xs) => [...xs].sort((a, b) => a - b);
17
+ const median = (xs) => { const s = sorted(xs), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
18
+ const quantile = (xs, q) => { const s = sorted(xs); return s[Math.min(s.length - 1, Math.max(0, Math.round(q * (s.length - 1))))]; };
19
+ const meanStep = (a, b) => { let sum = 0; for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]); return sum / a.length / 255; };
20
+
21
+ export const readPng = (file) => decodePng(readFileSync(file));
22
+ export const framesIn = (dir, prefix = 'f') => (existsSync(dir) ? readdirSync(dir).filter((n) => n.startsWith(prefix) && n.endsWith('.png')).sort().map((n) => join(dir, n)) : []);
23
+
24
+ // GIF container facts ffmpeg's raw pipe does not return: per-frame delays (ms) and the loop count.
25
+ export function gifTiming(bytes) {
26
+ if (bytes.toString('latin1', 0, 3) !== 'GIF') throw new Error('not a GIF file');
27
+ const delays = [];
28
+ let loop = null, delay = 100, at = 13;
29
+ if (bytes[10] & 0x80) at += 3 * (1 << ((bytes[10] & 7) + 1));
30
+ const skip = (p) => { while (p < bytes.length && bytes[p]) p += bytes[p] + 1; return p + 1; };
31
+ while (at < bytes.length) {
32
+ const block = bytes[at];
33
+ if (block === 0x3b) break;
34
+ if (block === 0x21) {
35
+ if (bytes[at + 1] === 0xf9) delay = 10 * bytes.readUInt16LE(at + 4);
36
+ if (bytes[at + 1] === 0xff && bytes.toString('latin1', at + 3, at + 14) === 'NETSCAPE2.0') loop = bytes.readUInt16LE(at + 16);
37
+ at = skip(at + 2);
38
+ } else if (block === 0x2c) {
39
+ let p = at + 10;
40
+ if (bytes[at + 9] & 0x80) p += 3 * (1 << ((bytes[at + 9] & 7) + 1));
41
+ at = skip(p + 1);
42
+ delays.push(delay > 0 ? delay : 100);
43
+ delay = 100;
44
+ } else break;
45
+ }
46
+ return { delays, loop, width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
47
+ }
48
+
49
+ // Load a cover: { lumas, delays, width, height, loop, bytes, format, kept }. keep(i, ms) is asked
50
+ // once per decoded frame, in order, whether to keep that frame whole (RGBA) for the stills.
51
+ export function loadCover(input, { fps = 16, keep = () => false, env = process.env } = {}) {
52
+ const lumas = [], kept = new Map();
53
+ if (statSync(input).isDirectory()) {
54
+ let width = 0, height = 0;
55
+ const files = framesIn(input);
56
+ files.forEach((file, i) => {
57
+ const img = readPng(file);
58
+ ({ width, height } = img);
59
+ lumas.push(lumaGrid(img.rgba, width, height).luma);
60
+ if (keep(i, 1000 / fps)) kept.set(i, img.rgba);
61
+ });
62
+ return { lumas, delays: files.map(() => 1000 / fps), width, height, loop: 0, bytes: null, format: 'png', kept };
63
+ }
64
+ if (input.toLowerCase().endsWith('.gif')) {
65
+ const bytes = readFileSync(input), timing = gifTiming(bytes);
66
+ const ffmpeg = which('ffmpeg', env);
67
+ if (!ffmpeg) throw new Error('ffmpeg is needed to decode a GIF cover');
68
+ const raw = spawnSync(ffmpeg, ['-v', 'error', '-i', input, '-fps_mode', 'passthrough', '-f', 'rawvideo', '-pix_fmt', 'rgba', 'pipe:1'], { maxBuffer: 2 ** 31, env });
69
+ if (raw.status !== 0) throw new Error(`ffmpeg could not decode ${input}`);
70
+ const size = timing.width * timing.height * 4;
71
+ for (let i = 0; (i + 1) * size <= raw.stdout.length; i++) {
72
+ const rgba = raw.stdout.subarray(i * size, (i + 1) * size);
73
+ lumas.push(lumaGrid(rgba, timing.width, timing.height).luma);
74
+ if (keep(i, timing.delays[i] ?? 100)) kept.set(i, Buffer.from(rgba));
75
+ }
76
+ return { lumas, delays: timing.delays.slice(0, lumas.length), width: timing.width, height: timing.height, loop: timing.loop, bytes: bytes.length, format: 'gif', kept };
77
+ }
78
+ const info = decodeWebp(input, {
79
+ env,
80
+ onFrame: (i, rgba, ms, width, height) => {
81
+ lumas.push(lumaGrid(rgba, width, height).luma);
82
+ if (keep(i, ms > 0 ? ms : 100)) kept.set(i, Buffer.from(rgba));
83
+ },
84
+ });
85
+ return { lumas, delays: info.durations.map((d) => (d > 0 ? d : 100)), width: info.width, height: info.height, loop: info.loop, bytes: info.bytes, format: 'webp', kept };
86
+ }
87
+
88
+ // The frame on screen at each grid time.
89
+ export function resample(frames, delays, stepMs) {
90
+ const total = delays.reduce((a, b) => a + b, 0), out = [];
91
+ let i = 0, end = delays[0];
92
+ for (let ms = 0; ms < total - 1e-6; ms += stepMs) {
93
+ while (ms >= end && i < frames.length - 1) end += delays[++i];
94
+ out.push(frames[i]);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ export function motionMetrics({ lumas, delays }) {
100
+ const stepMs = Math.max(COVER.gridMs, Math.min(...delays));
101
+ const grid = resample(lumas, delays, stepMs), n = grid.length;
102
+ const steps = [];
103
+ for (let i = 1; i < n; i++) steps.push(meanStep(grid[i], grid[i - 1]));
104
+ const seam = meanStep(grid[0], grid[n - 1]);
105
+ const ring = [...steps, seam];
106
+ const medianD = median(ring);
107
+ const still = ring.map((d) => d < COVER.stillEps);
108
+ let longest = 0;
109
+ if (still.every(Boolean)) longest = n * stepMs;
110
+ else {
111
+ // Walk the ring once from a moving step, so a hold that wraps the seam is counted whole.
112
+ const start = still.findIndex((s) => !s);
113
+ let run = 0;
114
+ for (let k = 1; k <= n; k++) { run = still[(start + k) % n] ? run + stepMs : 0; longest = Math.max(longest, run); }
115
+ }
116
+ const around = n >= 4 ? (ring[n - 3] + ring[n - 2] + ring[0] + ring[1]) / 4 : medianD;
117
+ return {
118
+ gridFrames: n, stepMs, durationSec: (n * stepMs) / 1000, sourceDurationSec: delays.reduce((a, b) => a + b, 0) / 1000,
119
+ medianD, p90D: quantile(ring, 0.9), maxD: Math.max(...ring), movingFrac: ring.filter((d) => d >= COVER.stillEps).length / ring.length,
120
+ longestStillSec: longest / 1000, spike: Math.max(...ring) / Math.max(medianD, COVER.stillEps), seam, seamLocal: seam / Math.max(COVER.stillEps, around), diffs: ring,
121
+ };
122
+ }
123
+
124
+ const LIN = Float64Array.from({ length: 256 }, (_, v) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
125
+ const relLum = (px, at) => 0.2126 * LIN[px[at]] + 0.7152 * LIN[px[at + 1]] + 0.0722 * LIN[px[at + 2]];
126
+ const boxPx = (box, W, H) => box.map((v, k) => Math.round(v * (k % 2 ? H : W)));
127
+
128
+ // G5: per frame, the median title pixel against the 90th-percentile background pixel of the title
129
+ // box (background = box pixels at least 3 px from any title pixel), through the per-frame mask.
130
+ export function titleContrast({ framesDir, masksDir, box }) {
131
+ const frames = framesIn(framesDir, 'f'), masks = framesIn(masksDir, 'm');
132
+ if (!frames.length || masks.length !== frames.length) return { measured: false, reason: `needs one mask per frame (${frames.length} frames, ${masks.length} masks)` };
133
+ const ratios = [];
134
+ for (let i = 0; i < frames.length; i++) {
135
+ const { width: W, height: H, rgba } = readPng(frames[i]);
136
+ const mask = readPng(masks[i]);
137
+ if (mask.width !== W || mask.height !== H) return { measured: false, reason: `mask ${i} is ${mask.width}x${mask.height}, the frame ${W}x${H}` };
138
+ const [bx, by, bw, bh] = boxPx(box, W, H);
139
+ const role = new Uint8Array(W * H);
140
+ for (let p = 0; p < W * H; p++) if (mask.rgba[p * 4] > 127) role[p] = 2;
141
+ for (let y = by; y < by + bh; y++) for (let x = bx; x < bx + bw; x++) {
142
+ if (role[y * W + x] !== 2) continue;
143
+ for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) {
144
+ const yy = y + dy, xx = x + dx;
145
+ if (yy >= 0 && yy < H && xx >= 0 && xx < W && role[yy * W + xx] === 0) role[yy * W + xx] = 1;
146
+ }
147
+ }
148
+ const text = [], ground = [];
149
+ for (let y = by; y < by + bh; y++) for (let x = bx; x < bx + bw; x++) {
150
+ const r = role[y * W + x];
151
+ if (r === 2) text.push(relLum(rgba, (y * W + x) * 4)); else if (r === 0) ground.push(relLum(rgba, (y * W + x) * 4));
152
+ }
153
+ if (!text.length || !ground.length) return { measured: false, reason: `frame ${i}: the title box holds ${text.length} title and ${ground.length} background pixels` };
154
+ const t = median(text), g = t > median(ground) ? quantile(ground, 0.9) : quantile(ground, 0.1);
155
+ ratios.push((Math.max(t, g) + 0.05) / (Math.min(t, g) + 0.05));
156
+ }
157
+ return { measured: true, min: Math.min(...ratios), median: median(ratios), frames: ratios.length };
158
+ }
159
+
160
+ // G6: the title's cap height at the README display width. In up to 12 sampled masks the white
161
+ // pixels inside the title box split into connected glyphs; dots and accents (under 1 % of the
162
+ // largest glyph's area or 30 % of the tallest glyph) are dropped and the median height of the rest
163
+ // is that frame's cap height. The smallest one, scaled from the cover width to displayWidth, must
164
+ // reach COVER.minCapPx. A title painted into declared base art is exempt: measured, reported INFO.
165
+ export function glyphHeights({ masksDir, box, displayWidth = COVER.displayWidth, exempt = null, samples = 12 }) {
166
+ const masks = framesIn(masksDir, 'm');
167
+ if (!masks.length) return { pass: false, reason: 'no mask frames to measure the title on' };
168
+ const n = Math.min(samples, masks.length);
169
+ const picks = [...new Set(Array.from({ length: n }, (_, k) => Math.floor((k * masks.length) / n)))];
170
+ let cap = Infinity, glyphs = 0, width = 0;
171
+ for (const i of picks) {
172
+ const m = readPng(masks[i]);
173
+ width = m.width;
174
+ const [bx, by, bw, bh] = boxPx(box, m.width, m.height);
175
+ const on = new Uint8Array(bw * bh);
176
+ for (let y = 0; y < bh; y++) for (let x = 0; x < bw; x++) on[y * bw + x] = m.rgba[((by + y) * m.width + bx + x) * 4] > 127 ? 1 : 0;
177
+ const parts = [];
178
+ for (let seed = 0; seed < on.length; seed++) {
179
+ if (on[seed] !== 1) continue;
180
+ const stack = [seed];
181
+ on[seed] = 2;
182
+ let area = 0, top = bh, bottom = -1;
183
+ while (stack.length) {
184
+ const q = stack.pop(), y = Math.floor(q / bw), x = q - y * bw;
185
+ area++; top = Math.min(top, y); bottom = Math.max(bottom, y);
186
+ for (const nb of [x > 0 ? q - 1 : -1, x < bw - 1 ? q + 1 : -1, y > 0 ? q - bw : -1, y < bh - 1 ? q + bw : -1]) if (nb >= 0 && on[nb] === 1) { on[nb] = 2; stack.push(nb); }
187
+ }
188
+ parts.push({ area, height: bottom - top + 1 });
189
+ }
190
+ if (!parts.length) return { pass: false, reason: `mask ${i}: no title pixels inside the title box` };
191
+ const biggest = Math.max(...parts.map((p) => p.area)), tallest = Math.max(...parts.map((p) => p.height));
192
+ const letters = parts.filter((p) => p.area >= 0.01 * biggest && p.height >= 0.3 * tallest);
193
+ glyphs = Math.max(glyphs, letters.length);
194
+ cap = Math.min(cap, median(letters.map((p) => p.height)));
195
+ }
196
+ const capPx = (cap * displayWidth) / width;
197
+ const row = { capPx: Number(capPx.toFixed(1)), coverPx: cap, displayWidth, glyphs, sampled: picks.length };
198
+ return exempt ? { pass: null, ...row, reason: exempt } : { pass: capPx >= COVER.minCapPx, ...row };
199
+ }
200
+
201
+ // G0: SHA-256 per frame file of two independent captures.
202
+ export function determinism(dirA, dirB) {
203
+ const a = framesIn(dirA, 'f'), b = framesIn(dirB, 'f');
204
+ const hash = (f) => createHash('sha256').update(readFileSync(f)).digest('hex');
205
+ let identical = 0, firstDiffering = null;
206
+ const n = Math.min(a.length, b.length);
207
+ for (let i = 0; i < n; i++) { if (hash(a[i]) === hash(b[i])) identical++; else if (firstDiffering === null) firstDiffering = i; }
208
+ return { compared: n, identical, countsMatch: a.length === b.length, firstDiffering };
209
+ }
210
+
211
+ // Gate one cover. Options: frames + frames2 (G0), masks + title (G5, G6), glyph ({ displayWidth,
212
+ // exempt }), fps (for a PNG folder input), keep. Returns the metrics, each gate, pass and target.
213
+ export function coverGate(input, { frames = null, frames2 = null, masks = null, title = null, glyph = {}, fps = 16, keep, env = process.env } = {}) {
214
+ const src = loadCover(input, { fps, keep, env });
215
+ const m = motionMetrics(src);
216
+ const F = COVER.floor, T = COVER.target, gates = {};
217
+ if (frames && frames2) {
218
+ const det = determinism(frames, frames2);
219
+ gates.G0 = { pass: det.compared > 0 && det.countsMatch && det.identical === det.compared, ...det };
220
+ }
221
+ if (src.format !== 'png') {
222
+ gates.G1 = {
223
+ pass: src.bytes <= COVER.maxBytes && m.sourceDurationSec >= COVER.minSec && m.sourceDurationSec <= COVER.maxSec && src.loop === 0,
224
+ target: src.bytes <= COVER.targetBytes, bytes: src.bytes, durationSec: m.sourceDurationSec, loop: src.loop, size: `${src.width}x${src.height}`,
225
+ };
226
+ }
227
+ 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 };
228
+ gates.G3 = { pass: m.longestStillSec <= F.stillSec, target: m.longestStillSec <= T.stillSec && m.spike <= T.spike, longestStillSec: m.longestStillSec, spike: m.spike };
229
+ gates.G4 = { pass: m.seamLocal <= F.seamLocal, target: m.seamLocal >= T.seamLo && m.seamLocal <= T.seamHi, seam: m.seam, seamLocal: m.seamLocal };
230
+ if (title && masks) {
231
+ const c = frames ? titleContrast({ framesDir: frames, masksDir: masks, box: title }) : { measured: false, reason: 'needs the captured frames (--frames) beside the masks' };
232
+ gates.G5 = c.measured ? { pass: c.min >= COVER.minContrast, min: c.min, median: c.median, frames: c.frames } : { pass: false, reason: c.reason };
233
+ gates.G6 = glyphHeights({ masksDir: masks, box: title, displayWidth: glyph.displayWidth ?? COVER.displayWidth, exempt: glyph.exempt ?? null });
234
+ }
235
+ return finishGate({ input, src, m, gates });
236
+ }
237
+
238
+ // Recompute the verdicts after a caller added gates (SEEK, ART, FLASH).
239
+ export function finishGate({ input, src, m, gates }) {
240
+ const pass = Object.values(gates).every((g) => g.pass !== false);
241
+ const target = pass && Object.values(gates).every((g) => g.target !== false);
242
+ return { input, format: src.format, width: src.width, height: src.height, bytes: src.bytes, loop: src.loop, sourceFrames: src.lumas.length, durations: src.delays, kept: src.kept, ...m, gates, pass, target, src, m };
243
+ }
244
+
245
+ const num = (x) => (typeof x === 'number' && !Number.isInteger(x) ? Number(x.toPrecision(4)) : x);
246
+
247
+ export function coverVerdict(r) {
248
+ return r.pass ? (r.target ? 'PASS+TARGET' : 'PASS(floor)') : 'FAIL';
249
+ }
250
+
251
+ // Report text: the strict verdict line, the file line, one line per gate.
252
+ export function formatCoverReport(r, { strict = true } = {}) {
253
+ const lines = [
254
+ `COVER gate${strict ? ' --strict' : ''}: ${coverVerdict(r)}`,
255
+ ` ${r.width}x${r.height} ${r.format} frames=${r.sourceFrames} dur=${num(r.sourceDurationSec)}s bytes=${r.bytes ?? '-'} loop=${r.loop} grid=${num(r.stepMs)}ms`,
256
+ ];
257
+ for (const [id, g] of Object.entries(r.gates)) {
258
+ const { pass, target, ...detail } = g;
259
+ const word = pass === null ? 'INFO' : pass ? (target === false ? 'pass(floor)' : 'pass') : 'FAIL';
260
+ lines.push(` ${id} ${word} ${Object.entries(detail).map(([k, v]) => `${k}=${num(v)}`).join(' ')}`.trimEnd());
261
+ }
262
+ return `${lines.join('\n')}\n`;
263
+ }
@@ -0,0 +1,103 @@
1
+ // Pixel helpers for cover mode, all on RGBA8 buffers and dependency-free so the gate also runs in
2
+ // the test suite without the runtime cache: a separable Lanczos-3 resize (the 2x seek capture is
3
+ // brought down to the cover size with it), the gate's luma grid (Rec. 709 weights on the encoded
4
+ // values, box-averaged to about 300 px wide, so every motion number is a thumbnail-scale number),
5
+ // a rectangle copy, and a tiled SSIM on luma for the frame-0 base-art check.
6
+
7
+ export const LUMA_WIDTH = 300;
8
+
9
+ const sinc = (x) => (x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x));
10
+ const lanczos3 = (x) => (Math.abs(x) < 3 ? sinc(x) * sinc(x / 3) : 0);
11
+
12
+ // One row of taps per output pixel: the first source index and normalised weights. When shrinking,
13
+ // the kernel is stretched by the scale so every source pixel contributes (no aliasing).
14
+ function taps(from, to) {
15
+ const scale = from / to, stretch = Math.max(1, scale), reach = 3 * stretch;
16
+ return Array.from({ length: to }, (_, i) => {
17
+ const centre = (i + 0.5) * scale - 0.5;
18
+ const first = Math.max(0, Math.ceil(centre - reach));
19
+ const last = Math.min(from - 1, Math.floor(centre + reach));
20
+ const weights = [];
21
+ for (let j = first; j <= last; j++) weights.push(lanczos3((j - centre) / stretch));
22
+ const total = weights.reduce((a, b) => a + b, 0);
23
+ return { first, weights: weights.map((w) => w / total) };
24
+ });
25
+ }
26
+
27
+ // Resize opaque RGBA8 with Lanczos-3, horizontal pass then vertical; alpha comes out 255.
28
+ export function lanczosResize(src, sw, sh, dw, dh) {
29
+ if (sw === dw && sh === dh) return Buffer.from(src.buffer, src.byteOffset, src.byteLength);
30
+ const tx = taps(sw, dw), ty = taps(sh, dh);
31
+ const mid = new Float32Array(dw * sh * 3);
32
+ for (let y = 0; y < sh; y++) {
33
+ for (let x = 0; x < dw; x++) {
34
+ const { first, weights } = tx[x];
35
+ let r = 0, g = 0, b = 0;
36
+ for (let k = 0, at = (y * sw + first) * 4; k < weights.length; k++, at += 4) {
37
+ const w = weights[k];
38
+ r += src[at] * w; g += src[at + 1] * w; b += src[at + 2] * w;
39
+ }
40
+ const o = (y * dw + x) * 3;
41
+ mid[o] = r; mid[o + 1] = g; mid[o + 2] = b;
42
+ }
43
+ }
44
+ const out = Buffer.alloc(dw * dh * 4);
45
+ const byte = (v) => (v <= 0 ? 0 : v >= 255 ? 255 : Math.round(v));
46
+ for (let y = 0; y < dh; y++) {
47
+ const { first, weights } = ty[y];
48
+ for (let x = 0; x < dw; x++) {
49
+ let r = 0, g = 0, b = 0;
50
+ for (let k = 0; k < weights.length; k++) {
51
+ const at = ((first + k) * dw + x) * 3, w = weights[k];
52
+ r += mid[at] * w; g += mid[at + 1] * w; b += mid[at + 2] * w;
53
+ }
54
+ const o = (y * dw + x) * 4;
55
+ out[o] = byte(r); out[o + 1] = byte(g); out[o + 2] = byte(b); out[o + 3] = 255;
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // Luma (0..255) box-averaged by an integer factor that brings the width near LUMA_WIDTH.
62
+ export function lumaGrid(rgba, width, height) {
63
+ const f = Math.max(1, Math.round(width / LUMA_WIDTH));
64
+ const w = Math.floor(width / f), h = Math.floor(height / f);
65
+ const luma = new Float32Array(w * h);
66
+ for (let y = 0; y < h * f; y++) {
67
+ const row = Math.floor(y / f) * w;
68
+ for (let x = 0, at = y * width * 4; x < w * f; x++, at += 4) luma[row + Math.floor(x / f)] += 0.2126 * rgba[at] + 0.7152 * rgba[at + 1] + 0.0722 * rgba[at + 2];
69
+ }
70
+ for (let i = 0; i < luma.length; i++) luma[i] /= f * f;
71
+ return { luma, w, h };
72
+ }
73
+
74
+ export function crop(rgba, width, [x, y, w, h]) {
75
+ const out = Buffer.alloc(w * h * 4);
76
+ const src = Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength);
77
+ for (let row = 0; row < h; row++) src.copy(out, row * w * 4, ((y + row) * width + x) * 4, ((y + row) * width + x + w) * 4);
78
+ return out;
79
+ }
80
+
81
+ // SSIM on luma, averaged over non-overlapping 8x8 tiles (the classic constants for 8-bit data).
82
+ export function ssim(a, b, width, height) {
83
+ const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2, T = 8;
84
+ const Y = (px, i) => 0.2126 * px[i * 4] + 0.7152 * px[i * 4 + 1] + 0.0722 * px[i * 4 + 2];
85
+ let sum = 0, tiles = 0;
86
+ for (let ty = 0; ty + T <= height; ty += T) {
87
+ for (let tx = 0; tx + T <= width; tx += T) {
88
+ let ma = 0, mb = 0;
89
+ const va = new Float64Array(T * T), vb = new Float64Array(T * T);
90
+ for (let k = 0; k < T * T; k++) {
91
+ const i = (ty + Math.floor(k / T)) * width + tx + (k % T);
92
+ va[k] = Y(a, i); vb[k] = Y(b, i); ma += va[k]; mb += vb[k];
93
+ }
94
+ ma /= T * T; mb /= T * T;
95
+ let sa = 0, sb = 0, cov = 0;
96
+ for (let k = 0; k < T * T; k++) { const da = va[k] - ma, db = vb[k] - mb; sa += da * da; sb += db * db; cov += da * db; }
97
+ const n = T * T - 1;
98
+ sum += ((2 * ma * mb + C1) * (2 * cov / n + C2)) / ((ma * ma + mb * mb + C1) * (sa / n + sb / n + C2));
99
+ tiles++;
100
+ }
101
+ }
102
+ return tiles ? sum / tiles : 0;
103
+ }
@@ -0,0 +1,36 @@
1
+ // Cover init script: runs in every cover document before any page script. The film stage gives a
2
+ // page a virtual clock; a cover page gets none, because a cover frame is a function of t alone and
3
+ // the renderer seeks frames out of order. Every clock and every unseeded entropy source therefore
4
+ // throws, and the call is remembered by name so the renderer can report it (exit 18) even when the
5
+ // page caught the error. CSS animations and transitions would run on the wall clock: they are off.
6
+ (function litCoverInit() {
7
+ 'use strict';
8
+ const seen = [];
9
+ const refuse = (name, fix) => function refused() {
10
+ if (!seen.includes(name)) seen.push(name);
11
+ throw new Error(`${name} is not available in a cover page; ${fix}`);
12
+ };
13
+ const fromT = 'draw every frame from t';
14
+ Math.random = refuse('Math.random', 'use LitLoop.rng(seed) for layout and t for motion');
15
+ const NativeDate = Date;
16
+ function CoverDate(...args) {
17
+ if (!new.target || args.length === 0) return refuse('Date', fromT)();
18
+ return Reflect.construct(NativeDate, args, new.target);
19
+ }
20
+ CoverDate.prototype = NativeDate.prototype;
21
+ CoverDate.now = refuse('Date.now', fromT);
22
+ CoverDate.parse = NativeDate.parse;
23
+ CoverDate.UTC = NativeDate.UTC;
24
+ Object.defineProperty(window, 'Date', { value: CoverDate, writable: true, configurable: true });
25
+ Object.defineProperty(Performance.prototype, 'now', { value: refuse('performance.now', fromT), writable: true, configurable: true });
26
+ window.requestAnimationFrame = refuse('requestAnimationFrame', 'the renderer calls renderFrame(t); a cover runs no loop of its own');
27
+ window.setInterval = refuse('setInterval', 'the renderer calls renderFrame(t); a cover runs no loop of its own');
28
+ if (window.crypto) Object.defineProperty(window.crypto, 'getRandomValues', { value: refuse('crypto.getRandomValues', 'use LitLoop.rng(seed)'), configurable: true });
29
+ const still = () => {
30
+ const style = document.createElement('style');
31
+ style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
32
+ (document.head || document.documentElement).appendChild(style);
33
+ };
34
+ if (document.documentElement) still(); else document.addEventListener('readystatechange', still, { once: true });
35
+ window.__litCoverMisuse = () => seen.slice();
36
+ })
@@ -0,0 +1,77 @@
1
+ // A small PNG codec on node:zlib for cover mode, so the cover gate, the sheet and the tests read and
2
+ // write frames without the runtime cache. It reads 8-bit grey, grey+alpha, RGB, RGBA and palette
3
+ // images (non-interlaced, which is all Chrome, dwebp and this module write) and writes RGB.
4
+ import { deflateSync, inflateSync } from 'node:zlib';
5
+
6
+ const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
7
+ const CRC = new Int32Array(256).map((_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c; });
8
+ const crc32 = (buf) => { let c = -1; for (const b of buf) c = CRC[(c ^ b) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; };
9
+
10
+ function chunk(type, data) {
11
+ const head = Buffer.alloc(8);
12
+ head.writeUInt32BE(data.length, 0);
13
+ head.write(type, 4, 'latin1');
14
+ const tail = Buffer.alloc(4);
15
+ tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
16
+ return Buffer.concat([head, data, tail]);
17
+ }
18
+
19
+ // RGBA8 (or RGB8 with channels 3) -> PNG bytes, colour type 2, filter "up" on every row.
20
+ export function encodePng(pixels, width, height, { channels = 4, level = 6 } = {}) {
21
+ const stride = width * 3;
22
+ const raw = Buffer.alloc((stride + 1) * height);
23
+ let previous = Buffer.alloc(stride);
24
+ for (let y = 0; y < height; y++) {
25
+ const row = Buffer.alloc(stride);
26
+ for (let x = 0; x < width; x++) for (let c = 0; c < 3; c++) row[x * 3 + c] = pixels[(y * width + x) * channels + c];
27
+ const at = y * (stride + 1);
28
+ raw[at] = 2;
29
+ for (let i = 0; i < stride; i++) raw[at + 1 + i] = (row[i] - previous[i]) & 255;
30
+ previous = row;
31
+ }
32
+ const ihdr = Buffer.alloc(13);
33
+ ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 2;
34
+ return Buffer.concat([SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level })), chunk('IEND', Buffer.alloc(0))]);
35
+ }
36
+
37
+ const paeth = (a, b, c) => { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; };
38
+
39
+ // PNG bytes -> { width, height, rgba }.
40
+ export function decodePng(bytes) {
41
+ if (!bytes.subarray(0, 8).equals(SIGNATURE)) throw new Error('not a PNG file');
42
+ let width = 0, height = 0, depth = 0, type = 0, interlace = 0, palette = null, alpha = null;
43
+ const idat = [];
44
+ for (let at = 8; at + 8 <= bytes.length;) {
45
+ const length = bytes.readUInt32BE(at), name = bytes.toString('latin1', at + 4, at + 8), data = bytes.subarray(at + 8, at + 8 + length);
46
+ if (name === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); depth = data[8]; type = data[9]; interlace = data[12]; }
47
+ else if (name === 'PLTE') palette = data;
48
+ else if (name === 'tRNS') alpha = data;
49
+ else if (name === 'IDAT') idat.push(data);
50
+ else if (name === 'IEND') break;
51
+ at += 12 + length;
52
+ }
53
+ if (depth !== 8 || interlace) throw new Error(`unsupported PNG (bit depth ${depth}, interlace ${interlace})`);
54
+ const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[type];
55
+ if (!channels) throw new Error(`unsupported PNG colour type ${type}`);
56
+ const raw = inflateSync(Buffer.concat(idat));
57
+ const stride = width * channels;
58
+ const px = Buffer.alloc(stride * height);
59
+ for (let y = 0; y < height; y++) {
60
+ const filter = raw[y * (stride + 1)];
61
+ const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
62
+ const out = y * stride, up = out - stride;
63
+ for (let i = 0; i < stride; i++) {
64
+ const a = i >= channels ? px[out + i - channels] : 0, b = y ? px[up + i] : 0, c = y && i >= channels ? px[up + i - channels] : 0;
65
+ const v = line[i];
66
+ px[out + i] = (filter === 0 ? v : filter === 1 ? v + a : filter === 2 ? v + b : filter === 3 ? v + ((a + b) >> 1) : v + paeth(a, b, c)) & 255;
67
+ }
68
+ }
69
+ const rgba = Buffer.alloc(width * height * 4);
70
+ for (let i = 0; i < width * height; i++) {
71
+ const o = i * 4, s = i * channels;
72
+ if (type === 3) { const k = px[s]; rgba[o] = palette[k * 3]; rgba[o + 1] = palette[k * 3 + 1]; rgba[o + 2] = palette[k * 3 + 2]; rgba[o + 3] = alpha && k < alpha.length ? alpha[k] : 255; }
73
+ else if (channels <= 2) { rgba[o] = rgba[o + 1] = rgba[o + 2] = px[s]; rgba[o + 3] = channels === 2 ? px[s + 1] : 255; }
74
+ else { rgba[o] = px[s]; rgba[o + 1] = px[s + 1]; rgba[o + 2] = px[s + 2]; rgba[o + 3] = channels === 4 ? px[s + 3] : 255; }
75
+ }
76
+ return { width, height, rgba };
77
+ }
@@ -0,0 +1,70 @@
1
+ // The cover's review image: decoded frames in a 4-wide grid above a plot of the whole loop's
2
+ // frame-to-frame difference (one point per grid step), with the still threshold (red dashes) and
3
+ // the median (green dots) drawn across it. The plot shows what single stills cannot: a hold is a
4
+ // flat run on the dashes, encoder pops are regular spikes, a reset is a spike at the right end.
5
+ // Drawn straight into pixels, like the film contact sheet, so it needs neither Chrome nor fonts;
6
+ // each tile carries its loop time in seconds. `marks` shade spans of the plot (a window render).
7
+ import { writeFileSync } from 'node:fs';
8
+ import { COVER } from './constants.mjs';
9
+ import { lanczosResize } from './cover-image.mjs';
10
+ import { encodePng } from './cover-png.mjs';
11
+
12
+ const GLYPHS = {
13
+ 0: '111101101101111', 1: '010110010010111', 2: '111001111100111', 3: '111001111001111', 4: '101101111001001',
14
+ 5: '111100111001111', 6: '111100111101111', 7: '111001001001001', 8: '111101111101111', 9: '111101111001111',
15
+ '.': '000000000000010', s: '011100010001110', '-': '000000111000000',
16
+ };
17
+
18
+ function fill(buf, W, x, y, w, h, [r, g, b], a = 1) {
19
+ for (let yy = Math.max(0, y); yy < y + h; yy++) for (let xx = Math.max(0, x); xx < Math.min(W, x + w); xx++) {
20
+ const o = (yy * W + xx) * 4;
21
+ if (o + 3 >= buf.length) return;
22
+ buf[o] = Math.round(buf[o] * (1 - a) + r * a); buf[o + 1] = Math.round(buf[o + 1] * (1 - a) + g * a); buf[o + 2] = Math.round(buf[o + 2] * (1 - a) + b * a); buf[o + 3] = 255;
23
+ }
24
+ }
25
+
26
+ function label(buf, W, x, y, text, scale = 3, colour = [235, 235, 225]) {
27
+ let cx = x;
28
+ for (const ch of String(text)) {
29
+ const glyph = GLYPHS[ch];
30
+ if (glyph) for (let i = 0; i < 15; i++) if (glyph[i] === '1') fill(buf, W, cx + (i % 3) * scale, y + Math.floor(i / 3) * scale, scale, scale, colour);
31
+ cx += 4 * scale;
32
+ }
33
+ }
34
+
35
+ function line(buf, W, x0, y0, x1, y1, colour, thick = 2) {
36
+ const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0)));
37
+ for (let k = 0; k <= n; k++) fill(buf, W, Math.round(x0 + ((x1 - x0) * k) / n) - (thick >> 1), Math.round(y0 + ((y1 - y0) * k) / n) - (thick >> 1), thick, thick, colour);
38
+ }
39
+
40
+ // tiles: [{ rgba, width, height, t }]; diffs: every grid step's d (the seam last); marks: [{ t0, t1 }]
41
+ // in seconds of `durationSec`. Writes an RGB PNG and returns its size.
42
+ export function writeCoverSheet({ file, tiles, diffs, medianD, durationSec, marks = [] }) {
43
+ const pad = 8, cols = 4, tileW = 400, tileH = Math.round((tileW * tiles[0].height) / tiles[0].width), cap = 24;
44
+ const rows = Math.ceil(tiles.length / cols);
45
+ const W = cols * tileW + (cols + 1) * pad;
46
+ const plotTop = rows * (tileH + cap + pad) + pad + 6, plotH = 170;
47
+ const H = plotTop + plotH + pad;
48
+ const buf = Buffer.alloc(W * H * 4);
49
+ fill(buf, W, 0, 0, W, H, [8, 11, 16]);
50
+ tiles.forEach((tile, k) => {
51
+ const x = pad + (k % cols) * (tileW + pad), y = pad + Math.floor(k / cols) * (tileH + cap + pad);
52
+ label(buf, W, x + 2, y + 4, `${tile.t.toFixed(2)}s`);
53
+ const small = lanczosResize(tile.rgba, tile.width, tile.height, tileW, tileH);
54
+ for (let row = 0; row < tileH; row++) small.copy(buf, ((y + cap + row) * W + x) * 4, row * tileW * 4, (row + 1) * tileW * 4);
55
+ });
56
+ const left = pad, width = W - 2 * pad;
57
+ fill(buf, W, left, plotTop, width, plotH, [17, 22, 29]);
58
+ for (const m of marks) {
59
+ const a = left + Math.round((m.t0 / durationSec) * width), b = left + Math.round((m.t1 / durationSec) * width);
60
+ fill(buf, W, a, plotTop, Math.max(2, b - a), plotH, [255, 176, 46], 0.18);
61
+ }
62
+ const top = Math.max(0.01, ...diffs) * 1.05;
63
+ const Y = (v) => Math.round(plotTop + plotH - 1 - (v / top) * (plotH - 2));
64
+ const X = (i) => Math.round(left + (i / Math.max(1, diffs.length - 1)) * (width - 1));
65
+ for (let x = left; x < left + width; x += 8) fill(buf, W, x, Y(COVER.stillEps), 4, 1, [170, 51, 51]);
66
+ for (let x = left; x < left + width; x += 5) fill(buf, W, x, Y(medianD), 2, 1, [85, 170, 136]);
67
+ for (let i = 1; i < diffs.length; i++) line(buf, W, X(i - 1), Y(diffs[i - 1]), X(i), Y(diffs[i]), [255, 122, 70]);
68
+ writeFileSync(file, encodePng(buf, W, H, { level: 6 }));
69
+ return { width: W, height: H };
70
+ }