@marver-design/marver 0.16.1 → 0.18.0

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 (43) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +3 -2
  3. package/dist/bake-jr38C_pX.mjs +747 -0
  4. package/dist/{build-B4yPgFNF.mjs → build-ER_7T6Cw.mjs} +179 -10
  5. package/dist/cli.mjs +21 -13
  6. package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
  7. package/dist/{daemon-Bbh_jmui.mjs → daemon-CKhg0zuT.mjs} +1 -1
  8. package/dist/{dev-DH2W7Ffw.mjs → dev-BvLbY98O.mjs} +208 -9
  9. package/dist/{init-B7YhcN2o.mjs → init-BWbHqVng.mjs} +2 -2
  10. package/dist/{manifest-CaslQIAO.mjs → manifest-DAnEL8_a.mjs} +1 -1
  11. package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
  12. package/dist/{plugin-BeBGu3gH.mjs → plugin-Cai5C1WV.mjs} +244 -47
  13. package/dist/{poster-CoyobbGW.mjs → poster-CIuz_PwH.mjs} +13 -5
  14. package/dist/publish-bakes-D0LhbQQ3.mjs +216 -0
  15. package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
  16. package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
  17. package/dist/shot-BFEuYbaz.mjs +86 -0
  18. package/dist/shot-iicees2e.mjs +933 -0
  19. package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
  20. package/docs/live-jam.md +20 -8
  21. package/docs/publish.md +27 -5
  22. package/package.json +1 -1
  23. package/src/client/frame-host/bridge.js +4 -9
  24. package/src/client/frame-host/main.tsx +20 -6
  25. package/src/client/shell/App.tsx +7 -0
  26. package/src/client/shell/Comments.tsx +2 -2
  27. package/src/client/shell/canvas/Canvas.tsx +2 -2
  28. package/src/client/shell/canvas/FrameNode.tsx +82 -89
  29. package/src/client/shell/canvas/admission.ts +70 -0
  30. package/src/client/shell/canvas/sleep.ts +194 -0
  31. package/src/client/shell/store.ts +14 -0
  32. package/src/client/shell/styles.css +9 -25
  33. package/src/shared/sleep-rule.ts +41 -0
  34. package/templates/AGENTS-embedded.md +4 -1
  35. package/templates/AGENTS-studio.md +4 -1
  36. package/templates/instructions/craft.md +9 -0
  37. package/templates/instructions/jam.md +15 -3
  38. package/templates/instructions/publish.md +62 -9
  39. package/templates/instructions/shape.md +2 -1
  40. package/dist/shot-By1AItpD.mjs +0 -30
  41. package/dist/shot-z-d-zMzf.mjs +0 -528
  42. package/src/client/frame-host/serialize.ts +0 -195
  43. package/src/client/shell/canvas/snapshots.ts +0 -233
@@ -0,0 +1,747 @@
1
+ import { AREA, SURFACE, pool, shotConcurrency, withBrowser } from "./shot-iicees2e.mjs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
+ import { join, sep } from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ //#region src/shared/sleep-rule.ts
6
+ /** The element's own background and filter, read once BEFORE any override touches it. */
7
+ function readOwn(cs) {
8
+ return {
9
+ img: cs.backgroundImage,
10
+ color: cs.backgroundColor,
11
+ size: cs.backgroundSize,
12
+ pos: cs.backgroundPosition,
13
+ rep: cs.backgroundRepeat,
14
+ org: cs.backgroundOrigin,
15
+ clip: cs.backgroundClip,
16
+ filter: cs.filter
17
+ };
18
+ }
19
+ function sleepRule(selector, o, texture) {
20
+ const img = o.img === "none" ? "" : o.img + ",";
21
+ const colorClip = o.clip.split(",").pop().trim() || "border-box";
22
+ return selector + "{backdrop-filter:none!important;-webkit-backdrop-filter:none!important;" + (o.filter === "none" ? "filter:opacity(1)!important;" : "") + "background-color:transparent!important;background-image:" + img + "linear-gradient(" + o.color + "," + o.color + "),url(\"" + texture + "\")!important;background-size:" + (img ? o.size + "," : "") + "auto,100% 100%!important;background-position:" + (img ? o.pos + "," : "") + "0 0,0 0!important;background-repeat:" + (img ? o.rep + "," : "") + "no-repeat,no-repeat!important;background-origin:" + (img ? o.org + "," : "") + "border-box,border-box!important;background-clip:" + (img ? o.clip + "," : "") + colorClip + ",border-box!important}";
23
+ }
24
+ //#endregion
25
+ //#region src/server/bake.ts
26
+ /**
27
+ * Effect compilation - what lets a hi-fi frame SLEEP on the canvas (spec 16).
28
+ *
29
+ * A frame at rest is its own live document. What makes a glass design unrenderable at scale is not
30
+ * layout or text but its effects: every `backdrop-filter` is a compositor render surface that reads
31
+ * back what is behind it on every composited frame, and under a scaled canvas the tiles behind it
32
+ * re-raster on every zoom step. At rest nothing behind a glass surface changes, so its filtered
33
+ * backdrop is a constant - a texture. This module computes those textures with Chrome itself, so
34
+ * the pixels are the compositor's own, and CERTIFIES each one before it ships:
35
+ *
36
+ * 1. render the frame headless at the node's width/height/theme (DPR 2) and settle it like the
37
+ * shot pipeline does, plus SVG images, running animations and a DOM-quiet window
38
+ * 2. DETECT every element with a computed backdrop-filter; level overlapping elements (an element
39
+ * over an earlier one bakes one level later, so its backdrop contains the earlier one's result)
40
+ * 3. a WARMED, STABLE reference: one hide-and-show cycle first (tiles under the targets then sit in
41
+ * the state every later capture is in), captured until two consecutive captures are identical
42
+ * 4. per level: hide this level and above with opacity (a descendant can undo `visibility`), ONE
43
+ * screenshot, and one filter page that cuts every crop at integer device pixels, tiles it 3x3
44
+ * with mirrored copies (Chrome's backdrop blur reads only the element's own region and mirrors
45
+ * at its edges) and applies the element's own filter list by CSS; one screenshot of that page,
46
+ * sliced back into the textures at TEXTURE_DSF
47
+ * 5. certification: apply everything, render, and diff the INSIDE of each element's rounded
48
+ * shape (eroded 3 device px - a composited edge and an inline edge differ by an AA halo) against
49
+ * the reference; then re-apply only what passed and certify THAT composition (a rejected
50
+ * neighbour changes a backdrop); then check that no element's box moved
51
+ *
52
+ * The override the shell will use is the one certified here (src/shared/sleep-rule.ts):
53
+ * `backdrop-filter: none` under a static `filter` (the containing block stays, the compositor layer
54
+ * goes) with the texture under the element's own background layers. Layout is never touched.
55
+ */
56
+ const DSF = 2;
57
+ /** Texture device px per CSS px. Blur is scale-tolerant: 1x matches 2x on identity at 2x zoom
58
+ * (29 px > 8 of 5.18 M, none > 32) at a quarter of the memory; 0.5x fails on 30 px pills. */
59
+ const TEXTURE_DSF = 1;
60
+ const DEADLINE_MS = 45e3;
61
+ /** The largest frame an ask may name, in CSS px: the shot pipeline's bitmap budget at DPR 2. */
62
+ const ASK_MAX = {
63
+ side: Math.floor(SURFACE / DSF),
64
+ area: Math.floor(AREA / 4)
65
+ };
66
+ const MAX_TARGETS = 200;
67
+ /** One element's texture, in CSS px (its 3x3 mirrored tiling is filtered in a page of its own). */
68
+ const MAX_TARGET_AREA = 4e6;
69
+ const KEYS_PER_GEN = 400;
70
+ /** Runs INSIDE the frame document: the effect census and the paint-order levelling. Assigns
71
+ * data-mv-bake ids so later passes address elements without re-walking. */
72
+ const DETECT = `(() => {
73
+ const sel = (el) => {
74
+ const seg = []
75
+ for (let cur = el; cur && cur !== document.documentElement; cur = cur.parentElement) {
76
+ if (cur.id) { seg.unshift('#' + CSS.escape(cur.id)); break }
77
+ const tag = cur.tagName.toLowerCase()
78
+ let n = 1
79
+ for (let s = cur.previousElementSibling; s; s = s.previousElementSibling) if (s.tagName === cur.tagName) n++
80
+ seg.unshift(tag + ':nth-of-type(' + n + ')')
81
+ }
82
+ return seg.join('>')
83
+ }
84
+ const glass = []
85
+ for (const el of document.querySelectorAll('*')) {
86
+ const cs = getComputedStyle(el)
87
+ const bf = cs.backdropFilter || cs.webkitBackdropFilter
88
+ if (!bf || bf === 'none') continue
89
+ const r = el.getBoundingClientRect()
90
+ if (r.width < 1 || r.height < 1) continue
91
+ el.setAttribute('data-mv-glass', '')
92
+ glass.push({ el, bf, r })
93
+ }
94
+ window.__mvBakeDetected = glass.length
95
+ // glass inside glass: the inner element reads its ancestor's UNFILTERED backdrop (measured in
96
+ // Chrome 152), which no texture on either of them reproduces - both stay live
97
+ const out = []
98
+ let i = 0
99
+ for (const { el, bf, r } of glass) {
100
+ if ((el.parentElement && el.parentElement.closest('[data-mv-glass]')) || el.querySelector('[data-mv-glass]')) continue
101
+ el.setAttribute('data-mv-bake', String(i))
102
+ out.push({ i: i++, sel: sel(el), rect: { x: r.x, y: r.y, w: r.width, h: r.height }, filter: bf })
103
+ }
104
+ for (const { el } of glass) el.removeAttribute('data-mv-glass')
105
+ const level = new Array(out.length).fill(0)
106
+ const hit = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
107
+ for (let k = 0; k < out.length; k++) for (let j = 0; j < k; j++) if (hit(out[k].rect, out[j].rect)) level[k] = Math.max(level[k], level[j] + 1)
108
+ return out.map((t, k) => ({ ...t, level: level[k] }))
109
+ })()`;
110
+ /** Runs inside the frame document: hide the targets at or above \`level\` (opacity, the authored
111
+ * inline declaration remembered and restored exactly), show the rest, apply \`bakes\` as the
112
+ * override the shell will use. */
113
+ const APPLY = `((level, bakes) => {
114
+ const readOwn = ${readOwn.toString()}
115
+ const sleepRule = ${sleepRule.toString()}
116
+ let st = document.getElementById('mv-bake-style')
117
+ if (!st) { st = document.createElement('style'); st.id = 'mv-bake-style'; document.head.appendChild(st) }
118
+ const rules = []
119
+ // no authored transition may animate the override in: the shell installs it the same way
120
+ for (const el of document.querySelectorAll('[data-mv-bake]')) el.style.setProperty('transition-property', 'none', 'important')
121
+ for (const el of document.querySelectorAll('[data-mv-bake]')) {
122
+ const i = el.getAttribute('data-mv-bake')
123
+ const b = bakes.find((x) => String(x.i) === i)
124
+ if (!b) continue
125
+ // the element's OWN background, read once before any override touches it
126
+ const own = el.dataset.mvOwn ? JSON.parse(el.dataset.mvOwn) : readOwn(getComputedStyle(el))
127
+ if (!el.dataset.mvOwn) el.dataset.mvOwn = JSON.stringify(own)
128
+ rules.push(sleepRule('[data-mv-bake="' + i + '"]', own, b.texture))
129
+ }
130
+ st.textContent = rules.join('\\n')
131
+ for (const el of document.querySelectorAll('[data-mv-bake]')) {
132
+ const i = Number(el.getAttribute('data-mv-bake'))
133
+ const t = window.__mvBakeTargets.find((x) => x.i === i)
134
+ if (t && t.level >= level) {
135
+ if (!('mvOpacity' in el.dataset)) el.dataset.mvOpacity = JSON.stringify([el.style.getPropertyValue('opacity'), el.style.getPropertyPriority('opacity')])
136
+ el.style.setProperty('opacity', '0', 'important')
137
+ } else if ('mvOpacity' in el.dataset) {
138
+ const [v, p] = JSON.parse(el.dataset.mvOpacity)
139
+ if (v) el.style.setProperty('opacity', v, p); else el.style.removeProperty('opacity')
140
+ delete el.dataset.mvOpacity
141
+ }
142
+ }
143
+ return true
144
+ })`;
145
+ /** Every element's box, for the geometry guard (a lost containing block would move a fixed child). */
146
+ const GEOMETRY = `Array.from(document.querySelectorAll('body *')).map((el) => { const r = el.getBoundingClientRect(); return [r.x, r.y, r.width, r.height] })`;
147
+ const PAINTED = `new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))`;
148
+ /** The filter page: ONE screenshot of the level comes in; every element's crop is cut from it in-page
149
+ * at integer device pixels, tiled 3x3 with mirrored copies, filtered by CSS with the element's own
150
+ * filter list and clipped to its box - stacked so one screenshot yields every texture. */
151
+ function filterPage(full, items, dsf, tex) {
152
+ return `<!doctype html><html><head><meta charset=utf-8><style>html,body{margin:0;background:transparent}.clip{overflow:hidden;position:absolute;left:0}canvas{position:absolute;display:block}</style></head><body>
153
+ <script>
154
+ const items = ${JSON.stringify(items)}, dsf = ${dsf}, tex = ${tex}
155
+ const img = new Image()
156
+ img.onload = () => {
157
+ let top = 0
158
+ for (const it of items) {
159
+ const sx = Math.round(it.x * dsf), sy = Math.round(it.y * dsf), sw = Math.round(it.w * dsf), sh = Math.round(it.h * dsf)
160
+ const cw = it.w, ch = it.h
161
+ const tw = Math.max(1, Math.round(cw * tex)), th = Math.max(1, Math.round(ch * tex))
162
+ const clip = document.createElement('div'); clip.className = 'clip'; clip.id = 'c' + it.i
163
+ clip.style.top = top + 'px'; clip.style.width = cw + 'px'; clip.style.height = ch + 'px'
164
+ clip.dataset.box = [Math.round(top * tex), tw, th].join(',')
165
+ const c = document.createElement('canvas'); c.width = 3 * tw; c.height = 3 * th
166
+ c.style.left = -cw + 'px'; c.style.top = -ch + 'px'; c.style.width = (3 * cw) + 'px'; c.style.height = (3 * ch) + 'px'; c.style.filter = it.filter
167
+ const g = c.getContext('2d')
168
+ for (let ty = 0; ty < 3; ty++) for (let tx = 0; tx < 3; tx++) {
169
+ g.save(); g.translate(tx * tw, ty * th)
170
+ const fx = tx === 1 ? 1 : -1, fy = ty === 1 ? 1 : -1
171
+ g.translate(fx < 0 ? tw : 0, fy < 0 ? th : 0); g.scale(fx, fy)
172
+ g.drawImage(img, sx, sy, sw, sh, 0, 0, tw, th); g.restore()
173
+ }
174
+ clip.appendChild(c); document.body.appendChild(clip)
175
+ top += Math.ceil(ch) + 8
176
+ }
177
+ document.body.style.height = top + 'px'
178
+ window.__mvReady = true
179
+ }
180
+ img.src = ${JSON.stringify(full)}
181
+ <\/script></body></html>`;
182
+ }
183
+ const SLICE = `((shot) => new Promise((res) => { const im = new Image(); im.onload = () => {
184
+ const out = {}
185
+ for (const clip of document.querySelectorAll('.clip')) {
186
+ const [y, w, h] = clip.dataset.box.split(',').map(Number)
187
+ const c = document.createElement('canvas'); c.width = w; c.height = h
188
+ c.getContext('2d').drawImage(im, 0, y, w, h, 0, 0, w, h)
189
+ out[clip.id.slice(1)] = c.toDataURL('image/png')
190
+ }
191
+ res(out)
192
+ }; im.src = 'data:image/png;base64,' + shot }))`;
193
+ /** Runs in the frame page: are two captures the same picture within GPU dither (no channel differs by more than 2)? */
194
+ const NEAR = `((a, b) => (async () => {
195
+ const load = (d) => new Promise((r) => { const im = new Image(); im.onload = () => r(im); im.src = 'data:image/png;base64,' + d })
196
+ const [ia, ib] = await Promise.all([load(a), load(b)])
197
+ const px = (im) => { const c = document.createElement('canvas'); c.width = im.width; c.height = im.height; const g = c.getContext('2d', { willReadFrequently: true }); g.drawImage(im, 0, 0); return g.getImageData(0, 0, c.width, c.height).data }
198
+ const A = px(ia), B = px(ib)
199
+ if (A.length !== B.length) return false
200
+ for (let i = 0; i < A.length; i++) if (Math.abs(A[i] - B[i]) > 2) return false
201
+ return true
202
+ })())`;
203
+ /** Runs in the frame page: diff \`baked\` against \`reference\` inside each target's rounded shape,
204
+ * and over the rest of the frame - an override may not change one pixel outside the boxes it owns. */
205
+ const CERTIFY = `((reference, baked, rects, width) => (async () => {
206
+ const load = (d) => new Promise((r) => { const im = new Image(); im.onload = () => r(im); im.src = 'data:image/png;base64,' + d })
207
+ const [ia, ib] = await Promise.all([load(reference), load(baked)])
208
+ const px = (im) => { const c = document.createElement('canvas'); c.width = im.width; c.height = im.height; const g = c.getContext('2d', { willReadFrequently: true }); g.drawImage(im, 0, 0); return { d: g.getImageData(0, 0, c.width, c.height).data, w: c.width, h: c.height } }
209
+ const A = px(ia), B = px(ib), k = A.w / width, E = 3
210
+ const owned = new Uint8Array(A.w * A.h)
211
+ const verdicts = rects.map((t) => {
212
+ const x0 = Math.max(0, Math.floor(t.rect.x * k) + E), y0 = Math.max(0, Math.floor(t.rect.y * k) + E)
213
+ const x1 = Math.min(A.w, Math.ceil((t.rect.x + t.rect.w) * k) - E), y1 = Math.min(A.h, Math.ceil((t.rect.y + t.rect.h) * k) - E)
214
+ const el = document.querySelector(t.sel); const cs = el ? getComputedStyle(el) : null
215
+ const rad = (v) => Math.max(0, Math.min(parseFloat(v) || 0, Math.min(t.rect.w, t.rect.h) / 2) * k - E)
216
+ const R = cs ? [rad(cs.borderTopLeftRadius), rad(cs.borderTopRightRadius), rad(cs.borderBottomRightRadius), rad(cs.borderBottomLeftRadius)] : [0, 0, 0, 0]
217
+ const inside = (x, y) => {
218
+ const cx = [x0 + R[0], x1 - R[1], x1 - R[2], x0 + R[3]], cy = [y0 + R[0], y0 + R[1], y1 - R[2], y1 - R[3]]
219
+ const c = x < x0 + R[0] && y < y0 + R[0] ? 0 : x >= x1 - R[1] && y < y0 + R[1] ? 1 : x >= x1 - R[2] && y >= y1 - R[2] ? 2 : x < x0 + R[3] && y >= y1 - R[3] ? 3 : -1
220
+ if (c < 0) return true
221
+ const dx = x + 0.5 - cx[c], dy = y + 0.5 - cy[c]
222
+ return dx * dx + dy * dy <= R[c] * R[c]
223
+ }
224
+ // the perimeter - the box minus the eroded shape - is where a composited edge and an inline
225
+ // edge differ by an anti-aliasing halo; a tint painted into a transparent border shows there
226
+ // as a solid band, so it has its own, looser gate
227
+ const X0 = Math.max(0, Math.floor(t.rect.x * k)), Y0 = Math.max(0, Math.floor(t.rect.y * k))
228
+ const X1 = Math.min(A.w, Math.ceil((t.rect.x + t.rect.w) * k)), Y1 = Math.min(A.h, Math.ceil((t.rect.y + t.rect.h) * k))
229
+ let maxErr = 0, bad = 0, n = 0, ringBad = 0, ringN = 0
230
+ for (let y = Y0; y < Y1; y++) for (let x = X0; x < X1; x++) {
231
+ owned[y * A.w + x] = 1
232
+ const i = (y * A.w + x) * 4
233
+ const d = Math.max(Math.abs(A.d[i] - B.d[i]), Math.abs(A.d[i+1] - B.d[i+1]), Math.abs(A.d[i+2] - B.d[i+2]))
234
+ if (x >= x0 && x < x1 && y >= y0 && y < y1 && inside(x, y)) { if (d > maxErr) maxErr = d; if (d > 8) bad++; n++ }
235
+ else { if (d > 48) ringBad++; ringN++ }
236
+ }
237
+ return { maxErr, bad, n, ringBad, ringN }
238
+ })
239
+ // outside every box: a changed pixel here is an anti-aliased edge next to a former layer (sparse,
240
+ // a few per block) or a real change of paint (a blob) - counted per 16x16 device-pixel block
241
+ const cols = Math.ceil(A.w / 16), blocks = new Uint16Array(cols * Math.ceil(A.h / 16))
242
+ let maxErr = 0, bad = 0, gt32 = 0, n = 0, blockMax = 0
243
+ for (let p = 0; p < owned.length; p++) {
244
+ if (owned[p]) continue
245
+ const i = p * 4
246
+ const d = Math.max(Math.abs(A.d[i] - B.d[i]), Math.abs(A.d[i+1] - B.d[i+1]), Math.abs(A.d[i+2] - B.d[i+2]))
247
+ if (d > maxErr) maxErr = d; if (d > 8) bad++; n++
248
+ if (d > 32) { gt32++; const b = ((p / A.w) >> 4) * cols + ((p % A.w) >> 4); if (++blocks[b] > blockMax) blockMax = blocks[b] }
249
+ }
250
+ return { verdicts, outside: { maxErr, bad, gt32, n, blockMax } }
251
+ })())`;
252
+ const passes = (v) => !!v && v.n >= 64 && v.maxErr <= 32 && v.bad <= v.n * .005 && v.ringBad <= v.ringN * .05;
253
+ /** The frame outside the boxes: at most 0.05 % of its pixels beyond 32 levels and no 16x16 block
254
+ * more than a quarter changed (measured: 154 of 2.5 M, 22 per block at worst, on the largest real
255
+ * frame; a 60x40 CSS px blob fills its blocks). */
256
+ const untouched = (o) => !!o && o.gt32 <= o.n * 5e-4 && o.blockMax <= 64;
257
+ /** Compile one frame inside a browser the caller owns. */
258
+ async function bakeIn(b, opts) {
259
+ const { url, width, height } = opts;
260
+ const t0 = Date.now();
261
+ const me = {};
262
+ let targetId;
263
+ const deadline = setTimeout(() => b.abort(me, "the bake timed out"), DEADLINE_MS);
264
+ const closeTarget = async (id) => {
265
+ let t;
266
+ await Promise.race([b.send("Target.closeTarget", { targetId: id }).catch(() => {}), new Promise((r) => {
267
+ t = setTimeout(r, 2e3);
268
+ })]);
269
+ clearTimeout(t);
270
+ };
271
+ try {
272
+ targetId = (await b.send("Target.createTarget", { url: "about:blank" }, void 0, me)).targetId;
273
+ const sessionId = (await b.send("Target.attachToTarget", {
274
+ targetId,
275
+ flatten: true
276
+ }, void 0, me)).sessionId;
277
+ const send = (m, p = {}, sid = sessionId) => b.send(m, p, sid, me);
278
+ const ev = async (expression, awaitPromise = false) => (await send("Runtime.evaluate", {
279
+ expression,
280
+ returnByValue: true,
281
+ awaitPromise
282
+ })).result?.value;
283
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
284
+ const capture = async () => (await send("Page.captureScreenshot", {
285
+ format: "png",
286
+ captureBeyondViewport: true,
287
+ clip: {
288
+ x: 0,
289
+ y: 0,
290
+ width,
291
+ height,
292
+ scale: 1
293
+ }
294
+ })).data;
295
+ await send("Emulation.setDeviceMetricsOverride", {
296
+ width,
297
+ height,
298
+ deviceScaleFactor: DSF,
299
+ mobile: false
300
+ });
301
+ await send("Page.enable");
302
+ await send("Runtime.enable");
303
+ const nav = await send("Page.navigate", { url });
304
+ if (nav?.errorText && nav.errorText !== "net::ERR_ABORTED") return {
305
+ ok: false,
306
+ error: `could not load the frame (${nav.errorText})`
307
+ };
308
+ const readyBy = Date.now() + 3e4;
309
+ let rendered = false;
310
+ while (!rendered && Date.now() < readyBy) {
311
+ rendered = !!await ev(`(() => { const el = document.getElementById('root') ?? document.body; return !!el && el.childElementCount > 0 && document.readyState !== 'loading' })()`);
312
+ if (!rendered) await wait(100);
313
+ }
314
+ if (!rendered) return {
315
+ ok: false,
316
+ error: "the frame never rendered"
317
+ };
318
+ await ev(`document.fonts.ready.then(() => true)`, true);
319
+ const unsettled = await ev(`new Promise((r) => { const t = Date.now(); const tick = () => {
320
+ const H = innerHeight, W = innerWidth
321
+ let pending = 0
322
+ if (typeof window.__mvLodBusy === 'function' && window.__mvLodBusy() > 0) pending++
323
+ if (typeof window.__mvPosterBusy === 'function' && window.__mvPosterBusy() > 0) pending++
324
+ for (const im of document.images) { if (im.complete) continue; const b = im.getBoundingClientRect(); if (b.bottom < 0 || b.top > H || b.right < 0 || b.left > W) continue; pending++ }
325
+ for (const c of document.querySelectorAll('.mv-chart')) if (!c.querySelector('svg, canvas')) pending++
326
+ for (const d of document.querySelectorAll('.mv-diagram')) if (!d.querySelector('.mv-diagram-svg svg, .mv-diagram-err')) pending++
327
+ if (!pending || Date.now() - t > 6000) r(pending); else setTimeout(tick, 60) }; tick() })`, true);
328
+ if (unsettled) return {
329
+ ok: false,
330
+ error: `the frame did not settle (${unsettled} image(s) or chart(s) still loading)`
331
+ };
332
+ await ev(`Promise.all(Array.from(document.querySelectorAll('image')).map((el) => { const href = el.getAttribute('href') || el.getAttribute('xlink:href'); if (!href) return 1; const im = new Image(); im.src = new URL(href, location.href).href; return im.decode().catch(() => 1) })).then(() => true)`, true);
333
+ await ev(`Promise.race([Promise.all(document.getAnimations().map((a) => a.finished.catch(() => 1))), new Promise((r) => setTimeout(r, 5000))]).then(() => true)`, true);
334
+ await ev(`new Promise((resolve) => { let timer = 0; const done = () => { mo.disconnect(); resolve(true) }; const mo = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(done, 250) }); mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }); timer = setTimeout(done, 250); setTimeout(done, 3000) })`, true);
335
+ await ev(PAINTED, true);
336
+ const err = await ev(`window.__mvFrameError || ''`);
337
+ if (err) return {
338
+ ok: false,
339
+ error: `the frame rendered an error - ${err}`
340
+ };
341
+ const targets = await ev(`(window.__mvBakeTargets = ${DETECT})`);
342
+ const effects = Number(await ev("window.__mvBakeDetected")) || 0;
343
+ if (!targets?.length) return {
344
+ ok: true,
345
+ targets: [],
346
+ levels: 0,
347
+ rejected: 0,
348
+ ms: Date.now() - t0,
349
+ effects
350
+ };
351
+ if (targets.length > MAX_TARGETS) return {
352
+ ok: false,
353
+ error: `too many effects to compile (${targets.length})`
354
+ };
355
+ if (targets.some((t) => t.rect.w * t.rect.h > MAX_TARGET_AREA)) return {
356
+ ok: false,
357
+ error: "an effect larger than the texture budget"
358
+ };
359
+ const levels = Math.max(...targets.map((t) => t.level)) + 1;
360
+ const geometryBefore = await ev(GEOMETRY);
361
+ await ev(`${APPLY}(0, [])`);
362
+ await ev(PAINTED, true);
363
+ await ev(`${APPLY}(${levels}, [])`);
364
+ await ev(PAINTED, true);
365
+ await wait(300);
366
+ let reference = await capture(), stable = false;
367
+ let previous = "";
368
+ for (let i = 0; i < 8; i++) {
369
+ await wait(400);
370
+ const again = await capture();
371
+ if (again === reference || await ev(`${NEAR}(${JSON.stringify(reference)}, ${JSON.stringify(again)})`, true)) {
372
+ stable = true;
373
+ break;
374
+ }
375
+ previous = reference;
376
+ reference = again;
377
+ }
378
+ if (!stable) {
379
+ if (process.env.MV_BAKE_DEBUG) {
380
+ mkdirSync(process.env.MV_BAKE_DEBUG, { recursive: true });
381
+ writeFileSync(join(process.env.MV_BAKE_DEBUG, `unstable-a-${Date.now()}.png`), Buffer.from(previous, "base64"));
382
+ writeFileSync(join(process.env.MV_BAKE_DEBUG, `unstable-b-${Date.now()}.png`), Buffer.from(reference, "base64"));
383
+ }
384
+ return {
385
+ ok: false,
386
+ error: "the frame never stopped changing - nothing to certify against"
387
+ };
388
+ }
389
+ const bakes = [];
390
+ const done = /* @__PURE__ */ new Map();
391
+ for (let level = 0; level < levels; level++) {
392
+ await ev(`${APPLY}(${level}, ${JSON.stringify(bakes)})`);
393
+ await ev(PAINTED, true);
394
+ const mine = targets.filter((t) => t.level === level);
395
+ const full = await capture();
396
+ const items = mine.map((t) => ({
397
+ i: t.i,
398
+ x: t.rect.x,
399
+ y: t.rect.y,
400
+ w: t.rect.w,
401
+ h: t.rect.h,
402
+ filter: t.filter
403
+ }));
404
+ const { targetId: ft } = await b.send("Target.createTarget", { url: "about:blank" }, void 0, me);
405
+ try {
406
+ const { sessionId: fs } = await b.send("Target.attachToTarget", {
407
+ targetId: ft,
408
+ flatten: true
409
+ }, void 0, me);
410
+ const totalH = items.reduce((a, c) => a + Math.ceil(c.h) + 9, 0);
411
+ await b.send("Emulation.setDeviceMetricsOverride", {
412
+ width: Math.ceil(Math.max(...items.map((c) => c.w))),
413
+ height: Math.min(16e3, totalH),
414
+ deviceScaleFactor: TEXTURE_DSF,
415
+ mobile: false
416
+ }, fs, me);
417
+ await b.send("Page.enable", {}, fs, me);
418
+ const { frameTree } = await b.send("Page.getFrameTree", {}, fs, me);
419
+ await b.send("Page.setDocumentContent", {
420
+ frameId: frameTree.frame.id,
421
+ html: filterPage(`data:image/png;base64,${full}`, items, DSF, TEXTURE_DSF)
422
+ }, fs, me);
423
+ await b.send("Runtime.evaluate", {
424
+ expression: `new Promise((r) => { const t = () => window.__mvReady ? requestAnimationFrame(() => requestAnimationFrame(() => r(true))) : setTimeout(t, 20); t() })`,
425
+ awaitPromise: true,
426
+ returnByValue: true
427
+ }, fs, me);
428
+ const fshot = (await b.send("Page.captureScreenshot", {
429
+ format: "png",
430
+ captureBeyondViewport: true
431
+ }, fs, me)).data;
432
+ const textures = (await b.send("Runtime.evaluate", {
433
+ expression: `${SLICE}(${JSON.stringify(fshot)})`,
434
+ awaitPromise: true,
435
+ returnByValue: true
436
+ }, fs, me)).result.value;
437
+ for (const t of mine) {
438
+ const texture = textures[String(t.i)];
439
+ if (!texture) continue;
440
+ bakes.push({
441
+ i: t.i,
442
+ texture
443
+ });
444
+ done.set(t.i, {
445
+ sel: t.sel,
446
+ rect: t.rect,
447
+ filter: t.filter,
448
+ level,
449
+ texture,
450
+ verified: false,
451
+ maxErr: 255,
452
+ bad: -1
453
+ });
454
+ }
455
+ } finally {
456
+ await closeTarget(ft);
457
+ }
458
+ }
459
+ const certify = async (set) => {
460
+ await ev(`${APPLY}(${levels}, ${JSON.stringify(set)})`);
461
+ await ev(PAINTED, true);
462
+ const baked = await capture();
463
+ if (process.env.MV_BAKE_DEBUG) {
464
+ const d = join(process.env.MV_BAKE_DEBUG, new URL(url).pathname.replace(/[^\w.-]+/g, "_"));
465
+ mkdirSync(d, { recursive: true });
466
+ writeFileSync(join(d, "reference.png"), Buffer.from(reference, "base64"));
467
+ writeFileSync(join(d, `baked-${set.length}.png`), Buffer.from(baked, "base64"));
468
+ }
469
+ const rects = set.map((s) => done.get(s.i)).map((t) => ({
470
+ sel: t.sel,
471
+ rect: t.rect
472
+ }));
473
+ return await ev(`${CERTIFY}(${JSON.stringify(reference)}, ${JSON.stringify(baked)}, ${JSON.stringify(rects)}, ${width})`, true);
474
+ };
475
+ let set = bakes, settled = false, outside;
476
+ for (let round = 0; set.length && round <= levels + 1 && !settled; round++) {
477
+ const { verdicts, outside: o } = await certify(set);
478
+ outside = o;
479
+ set.forEach((s, k) => {
480
+ const t = done.get(s.i);
481
+ t.verified = passes(verdicts[k]);
482
+ t.maxErr = verdicts[k]?.maxErr ?? 255;
483
+ t.bad = verdicts[k]?.bad ?? -1;
484
+ });
485
+ const next = set.filter((s) => done.get(s.i).verified);
486
+ settled = next.length === set.length;
487
+ set = next;
488
+ }
489
+ if (settled && set.length && !untouched(outside)) settled = false;
490
+ if (!settled) for (const s of set) done.get(s.i).verified = false;
491
+ const geometryAfter = await ev(GEOMETRY);
492
+ if (geometryBefore.length !== geometryAfter.length || geometryBefore.some((r, k) => r.some((v, j) => Math.abs(v - geometryAfter[k][j]) > .01))) for (const t of done.values()) t.verified = false;
493
+ const out = [...done.values()];
494
+ return {
495
+ ok: true,
496
+ targets: out,
497
+ levels,
498
+ rejected: out.filter((t) => !t.verified).length,
499
+ ms: Date.now() - t0,
500
+ outside,
501
+ effects
502
+ };
503
+ } catch (err) {
504
+ return {
505
+ ok: false,
506
+ error: err.message
507
+ };
508
+ } finally {
509
+ clearTimeout(deadline);
510
+ b.abort(me, "the bake finished");
511
+ if (targetId && !b.dead) await closeTarget(targetId);
512
+ }
513
+ }
514
+ /** Where a compiled frame lives on disk: design/.local/bakes/<generation>/<key>/. The generation
515
+ * is the dev server's source generation (bumped on any watched change), so a stale bake can never
516
+ * be served after an edit; older generations are removed as new ones appear. */
517
+ function bakeKey(ask) {
518
+ return createHash("sha1").update(`${ask.frame}|${ask.theme}|${Math.round(ask.w)}|${Math.round(ask.h)}`).digest("hex").slice(0, 16);
519
+ }
520
+ function cacheDir(root, gen, key) {
521
+ return join(root, "design", ".local", "bakes", String(gen), key);
522
+ }
523
+ /** The cache lives INSIDE the project: a `bakes` directory that resolves elsewhere (a symlink) is
524
+ * never read, written or pruned. */
525
+ function cacheInside(root) {
526
+ const base = join(root, "design", ".local", "bakes");
527
+ if (!existsSync(base)) return true;
528
+ try {
529
+ return realpathSync(base).startsWith(realpathSync(root) + sep);
530
+ } catch {
531
+ return false;
532
+ }
533
+ }
534
+ function readCached(root, gen, ask, urlBase) {
535
+ const key = bakeKey(ask);
536
+ const dir = cacheDir(root, gen, key);
537
+ try {
538
+ const meta = JSON.parse(readFileSync(join(dir, "bake.json"), "utf8"));
539
+ try {
540
+ const now = /* @__PURE__ */ new Date();
541
+ utimesSync(dir, now, now);
542
+ } catch {}
543
+ return {
544
+ ...ask,
545
+ ok: true,
546
+ targets: meta.targets.map((t) => ({
547
+ ...t,
548
+ texture: t.verified ? `${urlBase}/${gen}/${key}/${t.texture}` : ""
549
+ })),
550
+ levels: meta.levels,
551
+ rejected: meta.rejected,
552
+ ms: 0
553
+ };
554
+ } catch {
555
+ return null;
556
+ }
557
+ }
558
+ function writeCached(root, gen, ask, r, urlBase, protect) {
559
+ const key = bakeKey(ask);
560
+ const dir = cacheDir(root, gen, key);
561
+ const gdir = join(root, "design", ".local", "bakes", String(gen));
562
+ mkdirSync(gdir, { recursive: true });
563
+ if (!existsSync(join(gdir, "owner"))) writeFileSync(join(gdir, "owner"), String(process.pid));
564
+ try {
565
+ const names = readdirSync(gdir).filter((n) => !n.includes(".tmp-") && n !== key && n !== "owner" && !protect.has(n));
566
+ if (names.length >= KEYS_PER_GEN) names.map((n) => ({
567
+ n,
568
+ t: statSync(join(gdir, n)).mtimeMs
569
+ })).sort((a, b) => a.t - b.t).slice(0, names.length - KEYS_PER_GEN + 1).forEach(({ n }) => rmSync(join(gdir, n), {
570
+ recursive: true,
571
+ force: true
572
+ }));
573
+ } catch {}
574
+ const tmp = `${dir}.tmp-${process.pid}`;
575
+ rmSync(tmp, {
576
+ recursive: true,
577
+ force: true
578
+ });
579
+ mkdirSync(tmp, { recursive: true });
580
+ const targets = r.targets.map((t, i) => {
581
+ if (!t.verified) return {
582
+ ...t,
583
+ texture: ""
584
+ };
585
+ const name = `${i}.png`;
586
+ writeFileSync(join(tmp, name), Buffer.from(t.texture.slice(t.texture.indexOf(",") + 1), "base64"));
587
+ return {
588
+ ...t,
589
+ texture: name
590
+ };
591
+ });
592
+ writeFileSync(join(tmp, "bake.json"), JSON.stringify({
593
+ targets,
594
+ levels: r.levels,
595
+ rejected: r.rejected,
596
+ ms: r.ms,
597
+ outside: r.outside
598
+ }));
599
+ rmSync(dir, {
600
+ recursive: true,
601
+ force: true
602
+ });
603
+ try {
604
+ renameSync(tmp, dir);
605
+ } catch (e) {
606
+ rmSync(tmp, {
607
+ recursive: true,
608
+ force: true
609
+ });
610
+ throw new Error(`could not write the compiled frame: ${e.message}`);
611
+ }
612
+ return {
613
+ ...ask,
614
+ ok: true,
615
+ targets: targets.map((t) => ({
616
+ ...t,
617
+ texture: t.verified ? `${urlBase}/${gen}/${key}/${t.texture}` : ""
618
+ })),
619
+ levels: r.levels,
620
+ rejected: r.rejected,
621
+ ms: r.ms
622
+ };
623
+ }
624
+ const alive = (pid) => {
625
+ try {
626
+ process.kill(pid, 0);
627
+ return true;
628
+ } catch (e) {
629
+ return e.code === "EPERM";
630
+ }
631
+ };
632
+ /** Drop every generation but the current one and those another live server owns (called at start
633
+ * and when the generation bumps). */
634
+ function pruneBakes(root, keep) {
635
+ const base = join(root, "design", ".local", "bakes");
636
+ if (!existsSync(base) || !cacheInside(root)) return;
637
+ try {
638
+ for (const name of readdirSync(base)) {
639
+ if (name === String(keep)) continue;
640
+ let owner = 0;
641
+ try {
642
+ owner = Number(readFileSync(join(base, name, "owner"), "utf8"));
643
+ } catch {}
644
+ if (owner && owner !== process.pid && alive(owner)) continue;
645
+ rmSync(join(base, name), {
646
+ recursive: true,
647
+ force: true
648
+ });
649
+ }
650
+ } catch {}
651
+ }
652
+ /** Compiles in flight, by generation and key: identical asks - inside one request or across
653
+ * concurrent ones - share one compile. */
654
+ const inflight = /* @__PURE__ */ new Map();
655
+ /** Compile many frames as ONE operation (one browser, `shotConcurrency()` at a time), the cache
656
+ * first. `urlFor(ask)` gives the frame's own URL; `urlBase` is where textures are served from;
657
+ * `live()` is the source generation NOW - a compile the source outran is not published. */
658
+ async function bakeBatch(opts) {
659
+ const { root, gen, asks, urlFor, urlBase, live, log } = opts;
660
+ if (!cacheInside(root)) return asks.map((ask) => ({
661
+ ...ask,
662
+ ok: false,
663
+ error: "design/.local/bakes resolves outside the project"
664
+ }));
665
+ const answers = asks.map((ask) => readCached(root, gen, ask, urlBase));
666
+ const protect = new Set(asks.map(bakeKey));
667
+ const misses = /* @__PURE__ */ new Map();
668
+ asks.forEach((ask, i) => {
669
+ if (answers[i]) return;
670
+ const k = `${root}|${gen}|${bakeKey(ask)}`;
671
+ const m = misses.get(k);
672
+ if (m) m.at.push(i);
673
+ else misses.set(k, {
674
+ ask,
675
+ at: [i]
676
+ });
677
+ });
678
+ const mine = [];
679
+ const waits = [...misses.entries()].map(([k, m]) => {
680
+ let p = inflight.get(k);
681
+ if (!p) {
682
+ p = new Promise((resolve) => mine.push({
683
+ ask: m.ask,
684
+ resolve,
685
+ done: false
686
+ }));
687
+ inflight.set(k, p);
688
+ p.then(() => inflight.delete(k));
689
+ }
690
+ return p.then((a) => {
691
+ for (const i of m.at) answers[i] = {
692
+ ...a,
693
+ ...m.ask
694
+ };
695
+ });
696
+ });
697
+ if (mine.length) {
698
+ const settle = (job, a) => {
699
+ job.done = true;
700
+ job.resolve(a);
701
+ };
702
+ try {
703
+ await withBrowser("shot", (b) => pool(shotConcurrency(), mine, async (job) => {
704
+ const cached = readCached(root, gen, job.ask, urlBase);
705
+ if (cached) return settle(job, cached);
706
+ const r = await bakeIn(b, {
707
+ url: urlFor(job.ask),
708
+ width: Math.round(job.ask.w),
709
+ height: Math.round(job.ask.h)
710
+ }).catch((e) => ({
711
+ ok: false,
712
+ error: e.message
713
+ }));
714
+ if (live && live() !== gen) return settle(job, {
715
+ ...job.ask,
716
+ ok: false,
717
+ error: "the source changed during the compile"
718
+ });
719
+ let a;
720
+ try {
721
+ a = r.ok ? writeCached(root, gen, job.ask, r, urlBase, protect) : {
722
+ ...job.ask,
723
+ ...r
724
+ };
725
+ } catch (e) {
726
+ a = {
727
+ ...job.ask,
728
+ ok: false,
729
+ error: e.message
730
+ };
731
+ }
732
+ log?.(a);
733
+ settle(job, a);
734
+ }));
735
+ } catch (e) {
736
+ for (const job of mine) if (!job.done) settle(job, {
737
+ ...job.ask,
738
+ ok: false,
739
+ error: e.message
740
+ });
741
+ }
742
+ }
743
+ await Promise.all(waits);
744
+ return answers;
745
+ }
746
+ //#endregion
747
+ export { ASK_MAX, bakeBatch, pruneBakes };