@marver-design/marver 0.17.0 → 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.
- package/CHANGELOG.md +68 -0
- package/README.md +1 -0
- package/dist/bake-jr38C_pX.mjs +747 -0
- package/dist/{build-D_g53Bp2.mjs → build-ER_7T6Cw.mjs} +179 -10
- package/dist/cli.mjs +19 -11
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-CKhg0zuT.mjs} +1 -1
- package/dist/{dev-LnIISva5.mjs → dev-BvLbY98O.mjs} +204 -10
- package/dist/{init-B7YhcN2o.mjs → init-BWbHqVng.mjs} +2 -2
- package/dist/{manifest-CaslQIAO.mjs → manifest-DAnEL8_a.mjs} +1 -1
- package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
- package/dist/{plugin-D2msH1cj.mjs → plugin-Cai5C1WV.mjs} +107 -28
- package/dist/{poster-BEjUcQP3.mjs → poster-CIuz_PwH.mjs} +1 -1
- package/dist/publish-bakes-D0LhbQQ3.mjs +216 -0
- package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
- package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
- package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
- package/dist/{shot-DlmTO8AF.mjs → shot-iicees2e.mjs} +14 -7
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/publish.md +27 -5
- package/package.json +1 -1
- package/src/client/frame-host/bridge.js +4 -9
- package/src/client/frame-host/main.tsx +20 -6
- package/src/client/shell/App.tsx +7 -0
- package/src/client/shell/Comments.tsx +2 -2
- package/src/client/shell/canvas/Canvas.tsx +2 -2
- package/src/client/shell/canvas/FrameNode.tsx +82 -89
- package/src/client/shell/canvas/admission.ts +70 -0
- package/src/client/shell/canvas/sleep.ts +194 -0
- package/src/client/shell/store.ts +14 -0
- package/src/client/shell/styles.css +9 -25
- package/src/shared/sleep-rule.ts +41 -0
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/publish.md +62 -9
- package/src/client/frame-host/serialize.ts +0 -195
- 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 };
|