spine-html 0.4.1 → 0.5.1

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/NOTICE.md CHANGED
@@ -14,6 +14,7 @@ redistribution includes the Spine Runtimes license and copyright notice.
14
14
 
15
15
  ## Example assets
16
16
 
17
- The spineboy skeleton, atlas, and images used by the demo are owned by Esoteric
18
- Software. They are **not redistributed** in this repository; `scripts/fetch-assets.sh`
19
- downloads them from the official spine-runtimes repository for local evaluation.
17
+ The spineboy skeleton exports (JSON and binary), atlas, and images used by the demo
18
+ and the tests are owned by Esoteric Software. They are **not redistributed** in this
19
+ repository; `scripts/fetch-assets.sh` downloads them from the official spine-runtimes
20
+ repository for local evaluation.
package/README.md CHANGED
@@ -148,12 +148,78 @@ assets.dispose();
148
148
  `spine-html` is a third-party renderer and is not affiliated with or endorsed by
149
149
  Esoteric Software.
150
150
 
151
+ ### One atlas, several skeletons
152
+
153
+ Skeletons that share an atlas — a character's `-ess` and `-pro` exports, a whole
154
+ cast packed onto one page — load in two steps instead. Calling
155
+ `loadSkeletonAssets` once per skeleton would fetch and unpack the same pages
156
+ again and mint a second set of region blob URLs:
157
+
158
+ ```ts
159
+ import { loadAtlasAssets, loadSkeletonJson, SpineHtmlRenderer } from 'spine-html';
160
+
161
+ const shared = await loadAtlasAssets({ atlasUrl: '/spineboy/spineboy.atlas' });
162
+ const [essData, proData] = await Promise.all([
163
+ loadSkeletonJson(shared, '/spineboy/spineboy-ess.json'),
164
+ loadSkeletonJson(shared, '/spineboy/spineboy-pro.json'),
165
+ ]);
166
+
167
+ const ess = new SpineHtmlRenderer(essRoot, shared.regionImages);
168
+ const pro = new SpineHtmlRenderer(proRoot, shared.regionImages);
169
+
170
+ // Unloading: every renderer first, then the one dispose() that owns the bitmaps.
171
+ ess.dispose();
172
+ pro.dispose();
173
+ shared.dispose();
174
+ ```
175
+
176
+ `loadAtlasAssets` takes the atlas half of the options above (`resolvePage`,
177
+ `crossOrigin`, `fetch`) and `loadSkeletonJson` the skeleton half (`scale`,
178
+ `fetch`). Ownership is the thing to keep straight: the atlas assets belong to
179
+ the caller, reading a skeleton against them never frees them, and one
180
+ `regionImages` map is meant to be handed to several renderers — so there is
181
+ exactly one `dispose()` for however many skeletons were read. `loadSkeletonJson`
182
+ asks only for `{ atlas }`, so a caller that built its atlas by hand (below) can
183
+ still use it for the read. `loadSkeletonAssets` is these two calls with the
184
+ atlas half kept private: use it for one atlas and one skeleton, this for the
185
+ rest.
186
+
187
+ ### Binary exports (`.skel`)
188
+
189
+ Binary exports are read by a second parser, `SkeletonBinary`, which is as big as
190
+ the JSON one. It lives behind its own entry point so that consumers who only
191
+ ever read `.json` never carry it:
192
+
193
+ ```ts
194
+ import { loadAtlasAssets, SpineHtmlRenderer } from 'spine-html';
195
+ import { loadSkeletonBinary } from 'spine-html/binary';
196
+
197
+ const shared = await loadAtlasAssets({ atlasUrl: '/spineboy/spineboy.atlas' });
198
+ const data = await loadSkeletonBinary(shared, '/spineboy/spineboy-pro.skel');
199
+
200
+ const renderer = new SpineHtmlRenderer(rootElement, shared.regionImages);
201
+ // Unloading, as above: renderer.dispose(); shared.dispose();
202
+ ```
203
+
204
+ `loadSkeletonBinary` is `loadSkeletonJson` with the parser swapped: same
205
+ arguments, the same `{ scale, fetch }` options, the same ownership rule — it
206
+ never frees `assets`, whichever way it ends. Those two calls are the binary path
207
+ in full. There is no one-call `loadSkeletonAssetsBinary`, because the atlas half
208
+ is precisely what a caller reading binary usually wants kept in hand, and hiding
209
+ it again would only earn back a line.
210
+
211
+ The subpath is the whole mechanism, so import it as written: pulling
212
+ `loadSkeletonBinary` out of `'spine-html'` is not possible, by design. Worth
213
+ knowing about the format itself: binary exports are version-locked to the
214
+ runtime that reads them, so a `.skel` written by an editor newer than your
215
+ installed `@esotericsoftware/spine-core` fails in the read, not in the fetch.
216
+
151
217
  ### Loading it yourself
152
218
 
153
219
  `loadSkeletonAssets` is optional sugar over five `spine-core` calls, and the
154
220
  package works without it. Drop to the low-level path whenever you need
155
- something it does not do one atlas shared by several skeletons, a binary
156
- export, images that are already in memory:
221
+ something no loader does — images that are already in memory, an atlas that is
222
+ not fetched from a URL at all:
157
223
 
158
224
  ```ts
159
225
  import { TextureAtlas, AtlasAttachmentLoader, SkeletonJson }
@@ -181,6 +247,35 @@ it — and calling it twice is a no-op. Load once for the page's lifetime and yo
181
247
  can ignore it; load and unload repeatedly without it and you leak an atlas per
182
248
  cycle.
183
249
 
250
+ ### What unloading frees
251
+
252
+ `revokeRegions()` — and `assets.dispose()`, which just calls it — frees the blob
253
+ URLs `unpackRegions` minted, and nothing else. The rest is pinned by objects you
254
+ own: an atlas **page image** is held by the `DomTexture` you attached to its
255
+ page, and `SkeletonData` reaches that same image through every attachment
256
+ (`region.texture.getImage()`), so a page is released only once the atlas, the
257
+ skeleton data and every renderer drawing from them are gone. `atlas.dispose()`
258
+ does not do it — `DomTexture.dispose()` is a no-op, because the image is yours.
259
+ With the `webgl` mesh backend the shared blitter also uploads each page image on
260
+ first use, but that GL texture is not the module's forever: every renderer
261
+ drawing the page holds a reference to it, `renderer.dispose()` hands those back,
262
+ and the texture is deleted once the last renderer using that page is disposed —
263
+ a later frame that needs the page uploads it again. The cache is keyed weakly by
264
+ the page image, so a renderer dropped without `dispose()` costs GPU memory until
265
+ the context is lost, but never keeps the image itself alive.
266
+
267
+ A meshed skeleton samples the page bitmap every frame, so its decoded form stays
268
+ in use: a floor on the order of `page width × page height × 4` bytes per page —
269
+ 1 MiB for spineboy's single 1024×256 page. A rigid-only skeleton draws nothing
270
+ from the page after `unpackRegions`, and what a merely reachable, undrawn image
271
+ costs is up to the browser, not measurable from script.
272
+
273
+ Unload in this order:
274
+
275
+ 1. `renderer.dispose()`, for every renderer using the images.
276
+ 2. `revokeRegions(regionImages)` (or `assets.dispose()`).
277
+ 3. Drop your references to the atlas and the skeleton data.
278
+
184
279
  ### One part per page (loose part PNGs)
185
280
 
186
281
  Not every pipeline runs the Spine editor's texture packer. If your parts are
@@ -227,10 +322,28 @@ const renderer = new SpineHtmlRenderer(rootElement, regionImages);
227
322
  raster matches the screen: `devicePixelRatio * rootScale`). **Writing it
228
323
  reallocates every mesh canvas backing store on the next frame**, and each
229
324
  reallocation recreates a GPU surface — the cost that took real Safari to ~3 fps
230
- when it happened per frame. Set it when a layout settles, never per frame:
325
+ when it happened per frame. Each canvas is then sized from what the new ratio
326
+ needs, in both directions: lowering the ratio gives the backing pixels back
327
+ (the backing is grow-only *within* a ratio, not across a change of one). Set
328
+ it when a layout settles, never per frame:
231
329
  debounce resize drags and quantize the value instead of tracking it
232
330
  continuously. `renderer.canvasReallocCount` is the check — it must fall back to
233
331
  zero within a second or two.
332
+ - `renderer.syncPixelRatio()` — measures the root's effective on-screen scale
333
+ and sets `pixelRatio` to `devicePixelRatio × scale`, returning the ratio now
334
+ in effect. It appends a hidden 100 px box to the root, reads its box once and
335
+ removes it (the root itself is usually 0×0), so it is **one forced layout per
336
+ call** — which is exactly why the renderer never calls it for you: there is no
337
+ per-frame layout read anywhere in this library. Call it when a zoom or a
338
+ layout *settles* (gesture end, debounced resize), not during the drag. A
339
+ change under 0.1% is ignored, so layout jitter cannot churn GPU surfaces, and
340
+ a root that is not laid out (a `display: none` ancestor) leaves the ratio
341
+ alone. Under an ancestor rotation the measured box is inflated and the ratio
342
+ errs high — oversampling costs pixels, undersampling costs picture.
343
+ - `renderer.meshBackingPixels` — allocated mesh-canvas backing pixels
344
+ (Σ width × height), computed on demand. This is what `pixelRatio` moves
345
+ quadratically, and the cheapest way to see an oversampling stage; the demo
346
+ prints it in the stats line as `backing N Mpx`.
234
347
  - `renderer.meshBackend` — `'canvas2d'` (default) or `'webgl'`; same output, but
235
348
  heavy deforming scenes on Safari want `'webgl'` (see Measured above). Falls back
236
349
  to canvas2d automatically when WebGL is unavailable. Switching re-rasters every
@@ -238,6 +351,22 @@ const renderer = new SpineHtmlRenderer(rootElement, regionImages);
238
351
  - `renderer.triangleExpand` — clip overdraw in px that closes antialiased mesh
239
352
  seams (default 0.5). Also re-rasters every mesh once when changed.
240
353
 
354
+ **A zoomable stage.** The mesh tier rasters at `world × pixelRatio` in the
355
+ root's own coordinates, and it cannot see a CSS transform above the root — so
356
+ the most natural pan/zoom stage, `transform: scale(zoom)` on an ancestor, makes
357
+ it oversample by `1/zoom²` in backing pixels until the zoom is folded in. The
358
+ picture stays correct throughout, which is what makes this easy to ship: only
359
+ the allocation and the frame rate move. Call `syncPixelRatio()` when the zoom
360
+ settles, or set `pixelRatio = devicePixelRatio * zoom` yourself. Measured
361
+ on-device (Chromium, dpr 2, stage under `scale(0.25)`, ~2.2 M CSS px on screen):
362
+
363
+ | scene | `pixelRatio` | `meshBackingPixels` | fps |
364
+ | --- | --- | --- | --- |
365
+ | 57 meshes | `devicePixelRatio` (2) | 139.1 Mpx | 14–21 |
366
+ | 57 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 61 |
367
+ | 92 meshes | `devicePixelRatio` (2) | 141.1 Mpx | 36–39 |
368
+ | 92 meshes | `dpr × zoom` (0.5) | 9.1 Mpx | 60–61 |
369
+
241
370
  ## Demo (this repository)
242
371
 
243
372
  ```bash
@@ -282,6 +411,15 @@ resolving, so blob ownership — every unpacked URL freed, nothing the caller
282
411
  owns touched, nothing stranded by a failed load — is asserted rather than
283
412
  assumed.
284
413
 
414
+ The server is part of the test: a run tests whatever is being served on its
415
+ port. Two checkouts of this repository on one machine (a worktree, a second
416
+ clone) default to the same port 4321 and reuse an existing server, so the
417
+ second run quietly tests the first one's build — green, and about the wrong
418
+ tree. Give each concurrent checkout its own port with `TEST_PORT`
419
+ (`TEST_PORT=4333 bun run test`); setting it also disables server reuse, so a
420
+ busy port fails the run instead of being borrowed. Unset, nothing changes:
421
+ port 4321, reuse unless `CI`.
422
+
285
423
  Standing rule: **headless numbers are never Safari performance evidence** —
286
424
  nothing in the suite asserts timing, and headless-WebKit fps/ms readings do
287
425
  not transfer (software rasterizer, measured up to 28× off real Safari). The
@@ -30,14 +30,37 @@ export interface RegionImage {
30
30
  * rendering is pure DOM (one <img> per slot, one CSS matrix write per frame).
31
31
  *
32
32
  * A region that covers its whole page unrotated skips the cut and reuses the
33
- * page image URL — see the pass-through below.
33
+ * page image URL — see the pass-through in cutRegion.
34
+ *
35
+ * **The cuts run concurrently.** PNG encoding is asynchronous and the browser
36
+ * does it off the main thread, so the encodes are started together and awaited
37
+ * together instead of one `toBlob` per iteration: awaited in series a load
38
+ * costs the *sum* of every encode, which a throttled document turns into one
39
+ * full throttle period per region (measured: a single 8×8 `toBlob` took 7.5 s
40
+ * to call back in a hidden desktop Chromium pane). How many run at once is
41
+ * bounded by CUT_BACKING_BUDGET_PX above, not by an option.
42
+ *
43
+ * Concurrency changes nothing a caller can observe:
44
+ *
45
+ * - **Order.** The returned map is keyed in `atlas.regions` order whatever
46
+ * order the blobs arrive in — the map is built after the cuts settle, from
47
+ * the region list, not from the arrivals.
48
+ * - **Duplicate names.** The last region of a given name in atlas order wins,
49
+ * and the shadowed URL is revoked (and dropped from the ledger) rather than
50
+ * stranded.
51
+ * - **Failure.** Every URL this call minted is revoked before the error
52
+ * propagates, *including cuts that were still in flight when the first
53
+ * failure happened* — their blobs arrive later, and a load that walked away
54
+ * from them would mint owned URLs nobody could ever revoke. So a failure
55
+ * stops new cuts from starting but still waits for the started ones, and the
56
+ * error it raises is the one from the earliest region in atlas order (what
57
+ * the serial loop used to throw). Nothing here rejects unobserved.
34
58
  *
35
59
  * The blob URLs stay alive until revokeRegions() frees them — a document-wide
36
60
  * allocation the GC cannot reclaim on its own. Callers that load and unload
37
61
  * skeletons repeatedly (cutscenes, level transitions) must pair every
38
62
  * unpackRegions() with a revokeRegions(); a caller that loads once for the
39
- * page lifetime can ignore it. If this function throws part-way through,
40
- * everything it already minted is revoked before the error propagates.
63
+ * page lifetime can ignore it.
41
64
  */
42
65
  export declare function unpackRegions(atlas: TextureAtlas, pageImages: Map<string, HTMLImageElement>): Promise<Map<string, RegionImage>>;
43
66
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"DomTexture.d.ts","sourceRoot":"","sources":["../src/DomTexture.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,OAAO,EACP,YAAY,EAEZ,KAAK,aAAa,EAClB,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC;;;;GAIG;AACH,qBAAa,UAAW,SAAQ,OAAO;IACrC,UAAU,CAAC,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,aAAa,GAAG,IAAI;IACtE,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,IAAI;IACxD,OAAO,IAAI,IAAI;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAC;CAChB;AAkBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,YAAY,EACnB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,GACxC,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAqEnC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,IAAI,CAEpE"}
1
+ {"version":3,"file":"DomTexture.d.ts","sourceRoot":"","sources":["../src/DomTexture.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,OAAO,EACP,YAAY,EAEZ,KAAK,aAAa,EAClB,KAAK,WAAW,EACjB,MAAM,8BAA8B,CAAC;AAEtC;;;;GAIG;AACH,qBAAa,UAAW,SAAQ,OAAO;IACrC,UAAU,CAAC,UAAU,EAAE,aAAa,EAAE,UAAU,EAAE,aAAa,GAAG,IAAI;IACtE,QAAQ,CAAC,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,IAAI;IACxD,OAAO,IAAI,IAAI;CAChB;AAED,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAC;CAChB;AAsGD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,YAAY,EACnB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,GACxC,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CA0EnC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,IAAI,CAEpE"}
@@ -24,6 +24,81 @@ function revokeOwned(url) {
24
24
  return;
25
25
  URL.revokeObjectURL(url);
26
26
  }
27
+ /**
28
+ * Backing pixels the cuts still waiting for their PNG encode may hold at once.
29
+ *
30
+ * A started cut keeps its canvas alive until `toBlob` calls back, so starting
31
+ * every region at once peaks at Σ(region area) of canvas backing — on top of
32
+ * the decoded pages, which stay resident throughout. Measured by counting the
33
+ * simultaneously-alive cut canvases and their backing at `toBlob` (chromium):
34
+ * the demo's 40-region 1024×256 atlas peaks at all 40 cuts and 194,742 px
35
+ * (0.19 Mpx ≈ 0.74 MiB), which is nothing; a synthetic 256-region atlas on
36
+ * four 2048×2048 pages peaks at 16.78 Mpx ≈ 64 MiB, which doubles that
37
+ * atlas's resident cost for the length of the load.
38
+ *
39
+ * So the cuts are started against a budget rather than all at once. 4 Mpx is
40
+ * one 2048×2048 page's worth (≈ 16 MiB at 4 bytes/px): small atlases still run
41
+ * every region concurrently (spineboy: all 40, budget never engaged), and the
42
+ * synthetic one runs 64 cuts at a time — 4 waves instead of 256 serial
43
+ * encodes, for a quarter of the peak. A single region bigger than the budget
44
+ * is started anyway when nothing else is in flight, so the budget can never
45
+ * starve a cut.
46
+ */
47
+ const CUT_BACKING_BUDGET_PX = 4 * 1024 * 1024;
48
+ /**
49
+ * One region's bitmap: the whole-page pass-through, or a cut canvas encoded to
50
+ * a blob URL this module then owns.
51
+ *
52
+ * Everything before the `toBlob` await runs synchronously, so calling this is
53
+ * what "starts" a cut: the canvas is painted and the encode is handed to the
54
+ * browser before control comes back.
55
+ */
56
+ async function cutRegion(region, pageImages) {
57
+ const image = pageImages.get(region.page.name);
58
+ if (!image)
59
+ throw new Error(`Missing page image: ${region.page.name}`);
60
+ const w = region.width;
61
+ const h = region.height;
62
+ // Whole-page pass-through: an unrotated region covering its entire page
63
+ // would be cut into a pixel-for-pixel copy of the page image, so reuse
64
+ // the page URL instead — no canvas, no PNG re-encode, no second decoded
65
+ // copy in memory, and nothing to revoke afterwards. Atlases written as
66
+ // one part per page (the loose-part-PNG workflow, where every part is
67
+ // declared its own page) hit this for every single region.
68
+ //
69
+ // The test is against the image, not the atlas `size:` line: a page
70
+ // declared at the wrong size still goes through the cut, since the cut
71
+ // is what the region's coordinates actually describe.
72
+ if (region.degrees === 0 &&
73
+ region.x === 0 &&
74
+ region.y === 0 &&
75
+ w === image.naturalWidth &&
76
+ h === image.naturalHeight) {
77
+ return { url: image.src, width: w, height: h };
78
+ }
79
+ const canvas = document.createElement('canvas');
80
+ canvas.width = w;
81
+ canvas.height = h;
82
+ const ctx = canvas.getContext('2d');
83
+ if (!ctx)
84
+ throw new Error('2d context unavailable');
85
+ if (region.degrees === 90) {
86
+ // The region is packed rotated: it occupies an h×w rect in the page.
87
+ // Rotate it back so the bitmap is in artwork orientation.
88
+ ctx.translate(0, h);
89
+ ctx.rotate(-Math.PI / 2);
90
+ ctx.drawImage(image, region.x, region.y, h, w, 0, 0, h, w);
91
+ }
92
+ else {
93
+ ctx.drawImage(image, region.x, region.y, w, h, 0, 0, w, h);
94
+ }
95
+ const blob = await new Promise((resolve, reject) => {
96
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
97
+ });
98
+ const url = URL.createObjectURL(blob);
99
+ ownedUrls.add(url);
100
+ return { url, width: w, height: h };
101
+ }
27
102
  /**
28
103
  * Cuts every atlas region out of the page image into its own bitmap once at
29
104
  * load time, restoring 90° packing rotation, so the per-frame path never
@@ -33,78 +108,106 @@ function revokeOwned(url) {
33
108
  * rendering is pure DOM (one <img> per slot, one CSS matrix write per frame).
34
109
  *
35
110
  * A region that covers its whole page unrotated skips the cut and reuses the
36
- * page image URL — see the pass-through below.
111
+ * page image URL — see the pass-through in cutRegion.
112
+ *
113
+ * **The cuts run concurrently.** PNG encoding is asynchronous and the browser
114
+ * does it off the main thread, so the encodes are started together and awaited
115
+ * together instead of one `toBlob` per iteration: awaited in series a load
116
+ * costs the *sum* of every encode, which a throttled document turns into one
117
+ * full throttle period per region (measured: a single 8×8 `toBlob` took 7.5 s
118
+ * to call back in a hidden desktop Chromium pane). How many run at once is
119
+ * bounded by CUT_BACKING_BUDGET_PX above, not by an option.
120
+ *
121
+ * Concurrency changes nothing a caller can observe:
122
+ *
123
+ * - **Order.** The returned map is keyed in `atlas.regions` order whatever
124
+ * order the blobs arrive in — the map is built after the cuts settle, from
125
+ * the region list, not from the arrivals.
126
+ * - **Duplicate names.** The last region of a given name in atlas order wins,
127
+ * and the shadowed URL is revoked (and dropped from the ledger) rather than
128
+ * stranded.
129
+ * - **Failure.** Every URL this call minted is revoked before the error
130
+ * propagates, *including cuts that were still in flight when the first
131
+ * failure happened* — their blobs arrive later, and a load that walked away
132
+ * from them would mint owned URLs nobody could ever revoke. So a failure
133
+ * stops new cuts from starting but still waits for the started ones, and the
134
+ * error it raises is the one from the earliest region in atlas order (what
135
+ * the serial loop used to throw). Nothing here rejects unobserved.
37
136
  *
38
137
  * The blob URLs stay alive until revokeRegions() frees them — a document-wide
39
138
  * allocation the GC cannot reclaim on its own. Callers that load and unload
40
139
  * skeletons repeatedly (cutscenes, level transitions) must pair every
41
140
  * unpackRegions() with a revokeRegions(); a caller that loads once for the
42
- * page lifetime can ignore it. If this function throws part-way through,
43
- * everything it already minted is revoked before the error propagates.
141
+ * page lifetime can ignore it.
44
142
  */
45
143
  export async function unpackRegions(atlas, pageImages) {
46
- const result = new Map();
47
- // Duplicate region names would otherwise strand the shadowed URL in the
48
- // ledger with nothing left pointing at it.
49
- const put = (name, entry) => {
50
- const shadowed = result.get(name);
51
- if (shadowed)
52
- revokeOwned(shadowed.url);
53
- result.set(name, entry);
54
- };
55
- try {
56
- for (const region of atlas.regions) {
57
- const atlasRegion = region;
58
- const image = pageImages.get(atlasRegion.page.name);
59
- if (!image)
60
- throw new Error(`Missing page image: ${atlasRegion.page.name}`);
61
- const w = region.width;
62
- const h = region.height;
63
- // Whole-page pass-through: an unrotated region covering its entire page
64
- // would be cut into a pixel-for-pixel copy of the page image, so reuse
65
- // the page URL instead — no canvas, no PNG re-encode, no second decoded
66
- // copy in memory, and nothing to revoke afterwards. Atlases written as
67
- // one part per page (the loose-part-PNG workflow, where every part is
68
- // declared its own page) hit this for every single region.
69
- //
70
- // The test is against the image, not the atlas `size:` line: a page
71
- // declared at the wrong size still goes through the cut, since the cut
72
- // is what the region's coordinates actually describe.
73
- if (region.degrees === 0 &&
74
- atlasRegion.x === 0 &&
75
- atlasRegion.y === 0 &&
76
- w === image.naturalWidth &&
77
- h === image.naturalHeight) {
78
- put(atlasRegion.name, { url: image.src, width: w, height: h });
79
- continue;
144
+ const regions = atlas.regions;
145
+ const cuts = new Array(regions.length);
146
+ // The earliest-in-atlas-order failure is the one that propagates, so that a
147
+ // broken atlas reports the same region whichever encode happens to land
148
+ // first.
149
+ let failedIndex = -1;
150
+ let failure;
151
+ let inFlight = [];
152
+ let inFlightPixels = 0;
153
+ for (let index = 0; index < regions.length && failedIndex === -1; index++) {
154
+ const region = regions[index];
155
+ if (!region)
156
+ continue;
157
+ // A pass-through allocates no canvas, but it also resolves without waiting
158
+ // for anything, so charging it the region's area costs at most one drain.
159
+ const pixels = region.width * region.height;
160
+ while (inFlight.length > 0 && inFlightPixels + pixels > CUT_BACKING_BUDGET_PX) {
161
+ await Promise.race(inFlight.map((job) => job.promise));
162
+ inFlight = inFlight.filter((job) => !job.done);
163
+ if (failedIndex !== -1)
164
+ break;
165
+ }
166
+ if (failedIndex !== -1)
167
+ break;
168
+ inFlightPixels += pixels;
169
+ const job = { done: false, promise: Promise.resolve() };
170
+ // Jobs settle, they never reject: the failure is recorded here so that
171
+ // nothing is left for an unhandled-rejection handler to find.
172
+ job.promise = (async () => {
173
+ try {
174
+ cuts[index] = await cutRegion(region, pageImages);
80
175
  }
81
- const canvas = document.createElement('canvas');
82
- canvas.width = w;
83
- canvas.height = h;
84
- const ctx = canvas.getContext('2d');
85
- if (!ctx)
86
- throw new Error('2d context unavailable');
87
- if (region.degrees === 90) {
88
- // The region is packed rotated: it occupies an h×w rect in the page.
89
- // Rotate it back so the bitmap is in artwork orientation.
90
- ctx.translate(0, h);
91
- ctx.rotate(-Math.PI / 2);
92
- ctx.drawImage(image, atlasRegion.x, atlasRegion.y, h, w, 0, 0, h, w);
176
+ catch (error) {
177
+ if (failedIndex === -1 || index < failedIndex) {
178
+ failedIndex = index;
179
+ failure = error;
180
+ }
93
181
  }
94
- else {
95
- ctx.drawImage(image, atlasRegion.x, atlasRegion.y, w, h, 0, 0, w, h);
182
+ finally {
183
+ inFlightPixels -= pixels;
184
+ job.done = true;
96
185
  }
97
- const blob = await new Promise((resolve, reject) => {
98
- canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
99
- });
100
- const url = URL.createObjectURL(blob);
101
- ownedUrls.add(url);
102
- put(atlasRegion.name, { url, width: w, height: h });
103
- }
186
+ })();
187
+ inFlight.push(job);
188
+ }
189
+ // Every cut this call started is settled after this, so the cleanup below
190
+ // reaches the blobs that arrived after the first failure too.
191
+ await Promise.all(inFlight.map((job) => job.promise));
192
+ if (failedIndex !== -1) {
193
+ for (const cut of cuts)
194
+ if (cut)
195
+ revokeOwned(cut.url);
196
+ throw failure;
104
197
  }
105
- catch (error) {
106
- revokeRegions(result);
107
- throw error;
198
+ const result = new Map();
199
+ for (let index = 0; index < regions.length; index++) {
200
+ const region = regions[index];
201
+ const cut = cuts[index];
202
+ if (!region || !cut)
203
+ continue;
204
+ // Duplicate region names would otherwise strand the shadowed URL in the
205
+ // ledger with nothing left pointing at it. Map.set keeps the key where it
206
+ // first appeared, so atlas order survives the overwrite.
207
+ const shadowed = result.get(region.name);
208
+ if (shadowed)
209
+ revokeOwned(shadowed.url);
210
+ result.set(region.name, cut);
108
211
  }
109
212
  return result;
110
213
  }
@@ -1 +1 @@
1
- {"version":3,"file":"DomTexture.js","sourceRoot":"","sources":["../src/DomTexture.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"DomTexture.js","sourceRoot":"","sources":["../src/DomTexture.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,OAAO,GAKR,MAAM,8BAA8B,CAAC;AAEtC;;;;GAIG;AACH,MAAM,OAAO,UAAW,SAAQ,OAAO;IACrC,UAAU,CAAC,UAAyB,EAAE,UAAyB,IAAS,CAAC;IACzE,QAAQ,CAAC,MAAmB,EAAE,MAAmB,IAAS,CAAC;IAC3D,OAAO,KAAU,CAAC;CACnB;AAeD;;;;;;;GAOG;AACH,MAAM,SAAS,GAAG,IAAI,GAAG,EAAU,CAAC;AAEpC,yEAAyE;AACzE,SAAS,WAAW,CAAC,GAAW;IAC9B,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,GAAG,CAAC;QAAE,OAAO;IACnC,GAAG,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;AAC3B,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,qBAAqB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC;AAE9C;;;;;;;GAOG;AACH,KAAK,UAAU,SAAS,CACtB,MAA0B,EAC1B,UAAyC;IAEzC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,KAAK;QAAE,MAAM,IAAI,KAAK,CAAC,uBAAuB,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;IAEvE,MAAM,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC;IACvB,MAAM,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC;IAExB,wEAAwE;IACxE,uEAAuE;IACvE,wEAAwE;IACxE,uEAAuE;IACvE,sEAAsE;IACtE,2DAA2D;IAC3D,EAAE;IACF,oEAAoE;IACpE,uEAAuE;IACvE,sDAAsD;IACtD,IACE,MAAM,CAAC,OAAO,KAAK,CAAC;QACpB,MAAM,CAAC,CAAC,KAAK,CAAC;QACd,MAAM,CAAC,CAAC,KAAK,CAAC;QACd,CAAC,KAAK,KAAK,CAAC,YAAY;QACxB,CAAC,KAAK,KAAK,CAAC,aAAa,EACzB,CAAC;QACD,OAAO,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IACjD,CAAC;IAED,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC;IAChD,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC;IACjB,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;IAClB,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACpC,IAAI,CAAC,GAAG;QAAE,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;IAEpD,IAAI,MAAM,CAAC,OAAO,KAAK,EAAE,EAAE,CAAC;QAC1B,qEAAqE;QACrE,0DAA0D;QAC1D,GAAG,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACpB,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;QACzB,GAAG,CAAC,SAAS,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IAC7D,CAAC;SAAM,CAAC;QACN,GAAG,CAAC,SAAS,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;IAC7D,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACvD,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC;IAC3F,CAAC,CAAC,CAAC;IACH,MAAM,GAAG,GAAG,GAAG,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;IACtC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACnB,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;AACtC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,KAAmB,EACnB,UAAyC;IAEzC,MAAM,OAAO,GAAyB,KAAK,CAAC,OAAO,CAAC;IACpD,MAAM,IAAI,GAAG,IAAI,KAAK,CAA0B,OAAO,CAAC,MAAM,CAAC,CAAC;IAEhE,4EAA4E;IAC5E,wEAAwE;IACxE,SAAS;IACT,IAAI,WAAW,GAAG,CAAC,CAAC,CAAC;IACrB,IAAI,OAAgB,CAAC;IAOrB,IAAI,QAAQ,GAAU,EAAE,CAAC;IACzB,IAAI,cAAc,GAAG,CAAC,CAAC;IAEvB,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,IAAI,WAAW,KAAK,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC;QAC1E,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;QAC9B,IAAI,CAAC,MAAM;YAAE,SAAS;QACtB,2EAA2E;QAC3E,0EAA0E;QAC1E,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC;QAE5C,OAAO,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,cAAc,GAAG,MAAM,GAAG,qBAAqB,EAAE,CAAC;YAC9E,MAAM,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;YACvD,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YAC/C,IAAI,WAAW,KAAK,CAAC,CAAC;gBAAE,MAAM;QAChC,CAAC;QACD,IAAI,WAAW,KAAK,CAAC,CAAC;YAAE,MAAM;QAE9B,cAAc,IAAI,MAAM,CAAC;QACzB,MAAM,GAAG,GAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QAC7D,uEAAuE;QACvE,8DAA8D;QAC9D,GAAG,CAAC,OAAO,GAAG,CAAC,KAAK,IAAI,EAAE;YACxB,IAAI,CAAC;gBACH,IAAI,CAAC,KAAK,CAAC,GAAG,MAAM,SAAS,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;YACpD,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,IAAI,WAAW,KAAK,CAAC,CAAC,IAAI,KAAK,GAAG,WAAW,EAAE,CAAC;oBAC9C,WAAW,GAAG,KAAK,CAAC;oBACpB,OAAO,GAAG,KAAK,CAAC;gBAClB,CAAC;YACH,CAAC;oBAAS,CAAC;gBACT,cAAc,IAAI,MAAM,CAAC;gBACzB,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;YAClB,CAAC;QACH,CAAC,CAAC,EAAE,CAAC;QACL,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IACrB,CAAC;IAED,0EAA0E;IAC1E,8DAA8D;IAC9D,MAAM,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;IAEtD,IAAI,WAAW,KAAK,CAAC,CAAC,EAAE,CAAC;QACvB,KAAK,MAAM,GAAG,IAAI,IAAI;YAAE,IAAI,GAAG;gBAAE,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACtD,MAAM,OAAO,CAAC;IAChB,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC9C,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,OAAO,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC;QACpD,MAAM,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;QAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;QACxB,IAAI,CAAC,MAAM,IAAI,CAAC,GAAG;YAAE,SAAS;QAC9B,wEAAwE;QACxE,0EAA0E;QAC1E,yDAAyD;QACzD,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACzC,IAAI,QAAQ;YAAE,WAAW,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC;QACxC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAC/B,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,aAAa,CAAC,MAAgC;IAC5D,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE;QAAE,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;AAC9D,CAAC"}
@@ -20,6 +20,13 @@
20
20
  * preserveDrawingBuffer nor cross-frame content is relied on. The buffer
21
21
  * grows quantized and never shrinks, mirroring the per-part backing policy
22
22
  * (reallocating GPU surfaces per frame is a Safari killer).
23
+ *
24
+ * Page textures are the one thing here with a lifetime, and since this object
25
+ * outlives every renderer they are reference-counted: a renderer retains each
26
+ * page it queues a job for and releases them in dispose(), and the last
27
+ * release deletes the GL texture (a later job re-uploads it). The cache is
28
+ * keyed weakly by the page image, so a consumer that never disposes still
29
+ * loses nothing but GPU memory — this object never pins the caller's image.
23
30
  */
24
31
  export interface MeshBlitJob {
25
32
  /** Destination per-part canvas; the blit clears its full backing first. */
@@ -40,10 +47,22 @@ export interface MeshBlitJob {
40
47
  }
41
48
  declare class MeshGlBlitter {
42
49
  lost: boolean;
50
+ /**
51
+ * Page textures created minus deleted, i.e. how many uploads are live right
52
+ * now. Deterministic (no GC in the path), which is what the lifetime tests
53
+ * assert against — see tests/gl-textures.spec.ts.
54
+ */
55
+ liveTextureCount: number;
43
56
  private readonly canvas;
44
57
  private readonly gl;
45
58
  private readonly uResolution;
46
- private readonly textures;
59
+ /**
60
+ * Weak by the page image on purpose: a consumer that forgets dispose()
61
+ * leaks its texture until context loss, but dropping the image still lets
62
+ * the image (and then the WebGLTexture this entry holds) be collected.
63
+ * Reassigned wholesale on context loss, never iterated.
64
+ */
65
+ private textures;
47
66
  private readonly maxSize;
48
67
  private vertexData;
49
68
  /** Per-job packed rect origins, filled by flush(). */
@@ -56,6 +75,29 @@ declare class MeshGlBlitter {
56
75
  * cannot fit) so the caller can rasterize the batch on the canvas2d path.
57
76
  */
58
77
  flush(jobs: MeshBlitJob[]): boolean;
78
+ /**
79
+ * Declares one more user of `page`. The upload itself stays lazy (the first
80
+ * flush that binds the page does it) — this only counts, so that the last
81
+ * release() can free the texture instead of the module holding it forever.
82
+ * Callers retain once per page and hand the same page back exactly once;
83
+ * SpineHtmlRenderer keeps that ledger and settles it in dispose().
84
+ */
85
+ retain(page: HTMLImageElement): void;
86
+ /**
87
+ * Gives back one retain(). The GL texture goes when the last user of the
88
+ * page is gone; a later job for the same page simply uploads it again. A
89
+ * page the cache does not know (a release after a context loss dropped
90
+ * everything, or a double dispose) is a no-op, so this is safe to call
91
+ * unconditionally.
92
+ */
93
+ release(page: HTMLImageElement): void;
94
+ /**
95
+ * Context loss invalidates every texture handle at once. Drop the cache and
96
+ * the count instead of carrying dead handles (deleting them is pointless —
97
+ * the GPU side is already gone) and let the next frames re-upload if the
98
+ * caller keeps drawing; the renderer meanwhile falls back to canvas2d.
99
+ */
100
+ private markLost;
59
101
  private compile;
60
102
  private textureFor;
61
103
  }
@@ -1 +1 @@
1
- {"version":3,"file":"MeshGlBlitter.d.ts","sourceRoot":"","sources":["../src/MeshGlBlitter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,MAAM,WAAW,WAAW;IAC1B,2EAA2E;IAC3E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,+EAA+E;IAC/E,IAAI,EAAE,gBAAgB,CAAC;IACvB,mFAAmF;IACnF,QAAQ,EAAE,YAAY,CAAC;IACvB,mDAAmD;IACnD,GAAG,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IACvB,gDAAgD;IAChD,SAAS,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IAC7B,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AA4BD,cAAM,aAAa;IACjB,IAAI,UAAS;IAEb,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;IAC3D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAwB;IAC3C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAuB;IACnD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA6C;IACtE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,UAAU,CAA0B;IAC5C,sDAAsD;IACtD,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,KAAK,CAAgB;;IAoD7B;;;;OAIG;IACH,KAAK,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,OAAO;IAwGnC,OAAO,CAAC,OAAO;IAYf,OAAO,CAAC,UAAU;CAmBnB;AAID;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,GAAG,IAAI,CASvD"}
1
+ {"version":3,"file":"MeshGlBlitter.d.ts","sourceRoot":"","sources":["../src/MeshGlBlitter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,WAAW;IAC1B,2EAA2E;IAC3E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,+EAA+E;IAC/E,IAAI,EAAE,gBAAgB,CAAC;IACvB,mFAAmF;IACnF,QAAQ,EAAE,YAAY,CAAC;IACvB,mDAAmD;IACnD,GAAG,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IACvB,gDAAgD;IAChD,SAAS,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;IAC7B,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAoCD,cAAM,aAAa;IACjB,IAAI,UAAS;IACb;;;;OAIG;IACH,gBAAgB,SAAK;IAErB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;IAC3D,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAwB;IAC3C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAuB;IACnD;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAgD;IAChE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,UAAU,CAA0B;IAC5C,sDAAsD;IACtD,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,KAAK,CAAgB;;IAoD7B;;;;OAIG;IACH,KAAK,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,OAAO;IAwGnC;;;;;;OAMG;IACH,MAAM,CAAC,IAAI,EAAE,gBAAgB,GAAG,IAAI;IAMpC;;;;;;OAMG;IACH,OAAO,CAAC,IAAI,EAAE,gBAAgB,GAAG,IAAI;IAarC;;;;;OAKG;IACH,OAAO,CAAC,QAAQ;IAMhB,OAAO,CAAC,OAAO;IAYf,OAAO,CAAC,UAAU;CA6BnB;AAID;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,GAAG,IAAI,CASvD"}