@neta-art/cohub 12.5.0 → 13.0.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 (76) hide show
  1. package/README.md +35 -13
  2. package/dist/board/editor/editor.d.ts +7 -5
  3. package/dist/board/editor/index.js +1 -1
  4. package/dist/board/editor/interaction.js +1 -1
  5. package/dist/board/export/index.d.ts +1 -6
  6. package/dist/board/export/index.js +16 -10
  7. package/dist/board/export/node/index.d.ts +2 -7
  8. package/dist/board/export/node/index.js +2 -5
  9. package/dist/board/export/scene.d.ts +3 -7
  10. package/dist/board/export/scene.js +22 -26
  11. package/dist/board/model/asset-key.d.ts +5 -2
  12. package/dist/board/model/asset-key.js +13 -5
  13. package/dist/board/model/index.d.ts +5 -4
  14. package/dist/board/model/index.js +4 -3
  15. package/dist/board/model/media-playback.d.ts +4 -6
  16. package/dist/board/model/media-playback.js +4 -4
  17. package/dist/board/model/read.d.ts +7 -0
  18. package/dist/board/model/read.js +18 -0
  19. package/dist/board/model/themes.d.ts +50 -0
  20. package/dist/board/model/themes.js +44 -6
  21. package/dist/board/render/backdrop-layer.d.ts +21 -0
  22. package/dist/board/render/backdrop-layer.js +227 -0
  23. package/dist/board/render/backdrop.d.ts +45 -0
  24. package/dist/board/render/backdrop.js +62 -0
  25. package/dist/board/render/index.d.ts +3 -2
  26. package/dist/board/render/index.js +7 -6
  27. package/dist/board/render/renderers/arrow-card-renderer.js +1 -1
  28. package/dist/board/render/renderers/audio-card-renderer.js +2 -2
  29. package/dist/board/render/renderers/file-card-renderer.js +1 -1
  30. package/dist/board/render/renderers/frame-card-renderer.js +1 -1
  31. package/dist/board/render/renderers/model-card-renderer.js +1 -1
  32. package/dist/board/render/renderers/shape-card-renderer.js +1 -1
  33. package/dist/board/render/renderers/task-card-renderer.js +2 -2
  34. package/dist/board/render/renderers/text-card-renderer.js +2 -2
  35. package/dist/board/render/renderers/video-card-renderer.js +1 -1
  36. package/dist/board/render/theme.d.ts +2 -0
  37. package/dist/board/render/theme.js +4 -2
  38. package/dist/board/replica/index.d.ts +3 -2
  39. package/dist/board/replica/index.js +34 -24
  40. package/dist/board/replica/indexeddb.js +14 -85
  41. package/dist/board/stage/assets.d.ts +4 -3
  42. package/dist/board/stage/assets.js +186 -83
  43. package/dist/board/stage/backdrop.d.ts +25 -0
  44. package/dist/board/stage/backdrop.js +104 -0
  45. package/dist/board/stage/index.d.ts +2 -1
  46. package/dist/board/stage/scene.js +12 -5
  47. package/dist/board/stage/stage.d.ts +7 -2
  48. package/dist/board/stage/stage.js +44 -24
  49. package/dist/board/stage/texture-pool.d.ts +1 -1
  50. package/dist/board/stage/texture-pool.js +4 -5
  51. package/dist/chunks/app-realtime.js +1 -1
  52. package/dist/chunks/environment.d.ts +34 -21
  53. package/dist/chunks/environment.js +4 -3
  54. package/dist/chunks/http-error.js +28 -5
  55. package/dist/chunks/http.d.ts +87 -18
  56. package/dist/chunks/http.js +1855 -59
  57. package/dist/chunks/websocket.d.ts +9 -1
  58. package/dist/http.d.ts +2 -2
  59. package/dist/http.js +1 -1
  60. package/dist/index.d.ts +9 -3
  61. package/dist/index.js +6 -1080
  62. package/dist/indexeddb.js +79 -0
  63. package/dist/media.d.ts +18 -32
  64. package/dist/media.js +81 -67
  65. package/dist/protocol/dist/board-model.d.ts +33 -26
  66. package/dist/protocol/dist/board-model.js +21 -18
  67. package/dist/protocol/dist/board.d.ts +21 -1
  68. package/dist/protocol/dist/board.js +5 -1
  69. package/dist/protocol/dist/core/media.d.ts +32 -9
  70. package/dist/protocol/dist/core/media.js +203 -48
  71. package/dist/protocol/dist/index.d.ts +3 -3
  72. package/dist/voice-input.js +1 -1
  73. package/dist/websocket.js +37 -5
  74. package/package.json +1 -1
  75. package/dist/board/render/board-background.d.ts +0 -18
  76. package/dist/board/render/board-background.js +0 -104
@@ -1,7 +1,7 @@
1
- import { probeMediaInfo } from "../../protocol/dist/core/media.js";
2
- import { MEDIA_VARIANT_SIZES, imageVariantUrl, variantSource } from "../../media.js";
1
+ import { MEDIA_VARIANT_SIZES, fitsVideoFrames, imageVariantUrl, mediaHost, mediaVariantSize, variantSource, videoFrameUrl } from "../../protocol/dist/core/media.js";
2
+ import { loadMediaInfo, loadMediaSize } from "../../media.js";
3
3
  import { boardModelFormat } from "../model/media.js";
4
- import { boardAssetKey, boardAssetKeySource, boardAssetRefKey } from "../model/asset-key.js";
4
+ import { boardAssetKey, boardAssetKeySource, boardAssetRefKey, boardMediaNaturalSize } from "../model/asset-key.js";
5
5
  import { loadVideoThumbnailTexture, videoTextureNaturalSize } from "../render/video-thumbnail.js";
6
6
  import { planLruTrim } from "./lru.js";
7
7
  import { defaultBoardDeviceBudget } from "./device.js";
@@ -9,12 +9,43 @@ import { scheduleBoardIdle } from "./idle.js";
9
9
  import { boardImageTexturePool, loadBoardImageTexture, loadFittedImageTexture, readTextureTone, shrinkBoardTexture, unloadBoardImageTexture } from "./texture-pool.js";
10
10
  import { Texture } from "pixi.js";
11
11
  //#region src/board/stage/assets.ts
12
+ const SMALLEST_VARIANT = MEDIA_VARIANT_SIZES[0];
12
13
  function variantUrl(source, edge, cover) {
13
14
  if (!(edge <= 1536)) return null;
14
15
  const url = imageVariantUrl(source, edge, cover ? "cover" : "contain");
15
16
  return url && url !== source ? url : null;
16
17
  }
17
- const SMALLEST_VARIANT = MEDIA_VARIANT_SIZES[0];
18
+ const frameAspects = /* @__PURE__ */ new Map();
19
+ const FRAME_ASPECT_LIMIT = 2048;
20
+ const FRAME_SLACK = 1.25;
21
+ function learnAspect(source, { width, height }) {
22
+ if (!width || !height || frameAspects.has(source)) return;
23
+ frameAspects.set(source, width / height);
24
+ if (frameAspects.size > FRAME_ASPECT_LIMIT) frameAspects.delete(frameAspects.keys().next().value);
25
+ }
26
+ const FRAME_FACTS_WAIT_MS = 300;
27
+ async function factSize(source) {
28
+ const timeout = new Promise((resolve) => setTimeout(resolve, FRAME_FACTS_WAIT_MS, null));
29
+ const info = await Promise.race([loadMediaInfo(source, "video"), timeout]);
30
+ return info?.width && info.height ? {
31
+ width: info.width,
32
+ height: info.height
33
+ } : void 0;
34
+ }
35
+ function frameUrl(source, edge, natural) {
36
+ return videoFrameUrl(source, mediaVariantSize(edge), {
37
+ natural,
38
+ aspect: natural ? null : frameAspects.get(source)
39
+ });
40
+ }
41
+ function textureMedia(media, url) {
42
+ return media === "video" && url && variantSource(url)?.type === "video" ? "image" : media;
43
+ }
44
+ function slotOf(media, source, frameless = false) {
45
+ return media === "video" && !frameless && videoFrameUrl(source, SMALLEST_VARIANT) ? "image" : media;
46
+ }
47
+ const FRAMELESS = /* @__PURE__ */ new Error("Video frame failed");
48
+ const NO_PREVIEW = /* @__PURE__ */ new Error("No preview without decoding the video");
18
49
  function boundEdge({ width, height }, cover) {
19
50
  return cover ? Math.min(width, height) : Math.max(width, height);
20
51
  }
@@ -62,7 +93,8 @@ function createBoardAssetManager(options) {
62
93
  }
63
94
  await unloadBoardImageTexture(url, texture);
64
95
  });
65
- function releaseTexture(media, url, texture) {
96
+ function releaseTexture(asset, url, texture) {
97
+ const media = textureMedia(asset, url);
66
98
  const pendingKey = `${media}:${url}`;
67
99
  const promise = unloadTexture(url, texture, media).catch(() => {}).then(() => {
68
100
  if (url && pendingUnloads.get(pendingKey) === promise) pendingUnloads.delete(pendingKey);
@@ -78,11 +110,11 @@ function createBoardAssetManager(options) {
78
110
  if (typeof requestAnimationFrame === "function") requestAnimationFrame(release);
79
111
  else setTimeout(release, 0);
80
112
  }
81
- const resolveFileUrl = options.resolveFileUrl;
82
113
  const entries = /* @__PURE__ */ new Map();
114
+ const keysByPath = /* @__PURE__ */ new Map();
83
115
  const pathVersions = /* @__PURE__ */ new Map();
84
116
  const starters = /* @__PURE__ */ new Map();
85
- const failedModels = /* @__PURE__ */ new Set();
117
+ const unavailable = /* @__PURE__ */ new Set();
86
118
  const queue = [];
87
119
  const listeners = /* @__PURE__ */ new Set();
88
120
  let active = 0;
@@ -92,6 +124,7 @@ function createBoardAssetManager(options) {
92
124
  model: 0
93
125
  };
94
126
  let disposed = false;
127
+ let stopSource = null;
95
128
  let focus = null;
96
129
  let trimTimer = null;
97
130
  let trimSince = 0;
@@ -131,27 +164,52 @@ function createBoardAssetManager(options) {
131
164
  cancelTone = toneQueue.size > 0 ? scheduleBoardIdle(readNextTone, 0) : null;
132
165
  }
133
166
  const keys = /* @__PURE__ */ new WeakMap();
167
+ const itemEpochs = /* @__PURE__ */ new Map();
134
168
  let keyEpoch = 0;
135
169
  function managedAssetKey(item) {
136
- const cached = keys.get(item);
137
- if (cached?.epoch === keyEpoch) return cached.key;
138
- const key = resolveAssetKey(item);
139
- keys.set(item, {
140
- epoch: keyEpoch,
141
- key
142
- });
143
- return key;
170
+ const itemEpoch = itemEpochs.get(item.id) ?? 0;
171
+ let record = keys.get(item);
172
+ if (record?.epoch !== keyEpoch || record.itemEpoch !== itemEpoch) {
173
+ const { key: base, natural } = resolveAsset(item);
174
+ const source = base ? boardAssetKeySource(base) : null;
175
+ const path = source?.kind === "file" ? source.value : null;
176
+ record = {
177
+ epoch: keyEpoch,
178
+ itemEpoch,
179
+ base,
180
+ path,
181
+ version: 0,
182
+ key: base,
183
+ natural
184
+ };
185
+ keys.set(item, record);
186
+ }
187
+ if (record.path !== null) {
188
+ const version = pathVersions.get(record.path) ?? 0;
189
+ if (version !== record.version) {
190
+ record.version = version;
191
+ record.key = `local:${version}:${record.base}`;
192
+ }
193
+ }
194
+ return record.key;
144
195
  }
145
- function resolveAssetKey(item) {
196
+ function resolveAsset(item) {
146
197
  const asset = options.itemAsset?.(item);
198
+ const natural = (asset === void 0 ? boardMediaNaturalSize(item) : asset?.natural) ?? null;
147
199
  const key = asset === void 0 ? boardAssetKey(item) : asset && boardAssetRefKey(asset);
148
- if (!key) return null;
149
- const source = boardAssetKeySource(key);
150
- if (source?.media === "video" && options.loadVideoPreviews === false) return null;
151
- if (source?.media === "model" && !modelPreviewable(item)) return null;
152
- if (source?.kind !== "file") return key;
153
- const version = pathVersions.get(source.value) ?? 0;
154
- return version > 0 ? `local:${version}:${key}` : key;
200
+ const source = key ? boardAssetKeySource(key) : null;
201
+ if (source?.media === "video" && source.kind === "url" && options.loadVideoPreviews === false && slotOf("video", source.value) === "video") return {
202
+ key: null,
203
+ natural
204
+ };
205
+ if (source?.media === "model" && !modelPreviewable(item)) return {
206
+ key: null,
207
+ natural
208
+ };
209
+ return {
210
+ key,
211
+ natural
212
+ };
155
213
  }
156
214
  function clearRetry(entry) {
157
215
  if (entry.retryTimer) {
@@ -163,9 +221,11 @@ function createBoardAssetManager(options) {
163
221
  let entry = entries.get(key);
164
222
  if (!entry) {
165
223
  const source = boardAssetKeySource(key);
224
+ const path = source?.kind === "file" ? source.value : null;
166
225
  entry = {
167
226
  media: source?.media ?? "image",
168
227
  cover: source?.fit === "cover",
228
+ path,
169
229
  source: null,
170
230
  url: null,
171
231
  texture: null,
@@ -177,11 +237,17 @@ function createBoardAssetManager(options) {
177
237
  loading: false,
178
238
  error: false,
179
239
  attempts: 0,
240
+ frameless: false,
180
241
  retryAt: 0,
181
242
  retryTimer: null,
182
243
  lastUsedAt: now()
183
244
  };
184
245
  entries.set(key, entry);
246
+ if (path !== null) {
247
+ const keyed = keysByPath.get(path);
248
+ if (keyed) keyed.add(key);
249
+ else keysByPath.set(path, /* @__PURE__ */ new Set([key]));
250
+ }
185
251
  }
186
252
  return entry;
187
253
  }
@@ -203,6 +269,10 @@ function createBoardAssetManager(options) {
203
269
  starters.delete(key);
204
270
  if (entry.url || entry.texture) releaseTexture(entry.media, entry.url ?? "", entry.texture);
205
271
  entries.delete(key);
272
+ if (entry.path === null) return;
273
+ const keyed = keysByPath.get(entry.path);
274
+ keyed?.delete(key);
275
+ if (keyed?.size === 0) keysByPath.delete(entry.path);
206
276
  }
207
277
  function scheduleTrim() {
208
278
  const at = now();
@@ -214,8 +284,8 @@ function createBoardAssetManager(options) {
214
284
  if (!disposed) trim();
215
285
  }, TRIM_DELAY);
216
286
  }
217
- function shrunkFootprint({ media, texture }) {
218
- if (!shrinkTexture || media !== "image" || !texture) return void 0;
287
+ function shrunkFootprint({ media, texture, url }) {
288
+ if (!shrinkTexture || textureMedia(media, url ?? "") !== "image" || !texture) return void 0;
219
289
  const scale = SHRINK_EDGE / Math.max(texture.width, texture.height);
220
290
  return scale < 1 ? Math.round(texture.width * scale) * Math.round(texture.height * scale) * 4 : void 0;
221
291
  }
@@ -275,7 +345,9 @@ function createBoardAssetManager(options) {
275
345
  entry.texture = texture;
276
346
  entry.url = loaded.url;
277
347
  entry.edge = loaded.edge;
278
- if (loaded.edge === Number.POSITIVE_INFINITY && !variantSource(loaded.url)) entry.natural = videoTextureNaturalSize(texture) ?? {
348
+ const decoded = videoTextureNaturalSize(texture);
349
+ if (decoded) entry.natural = decoded;
350
+ else if (loaded.edge === Number.POSITIVE_INFINITY && !variantSource(loaded.url)) entry.natural = {
279
351
  width: texture.width,
280
352
  height: texture.height
281
353
  };
@@ -292,23 +364,27 @@ function createBoardAssetManager(options) {
292
364
  notify(key);
293
365
  return;
294
366
  }
295
- const media = entry.media;
367
+ if (error === FRAMELESS && entry.source && entry.refs > 0) return enqueue(key, "video", textureLoad("video", entry.source));
368
+ const final = entry.media === "model" || error === NO_PREVIEW;
296
369
  entry.error = true;
297
370
  entry.attempts += 1;
298
371
  entry.retryAt = now() + Math.min(1e3 * 2 ** entry.attempts, MAX_RETRY_DELAY);
299
- console.warn(`[board] failed to load preview for ${key}`, error);
300
- if (media === "model") failedModels.add(key);
372
+ if (error !== NO_PREVIEW) console.warn(`[board] failed to load preview for ${key}`, error);
373
+ if (final) unavailable.add(key);
301
374
  if (entry.refs <= 0) evict(key, entry);
302
375
  else {
303
- if (media !== "model") scheduleRetry(key, entry);
376
+ if (!final) scheduleRetry(key, entry);
304
377
  notify(key);
305
378
  }
306
379
  }
307
380
  function refinable(key, entry) {
308
- return entry.media === "image" && (entry.refs > 0 || warm.has(key)) && entry.texture !== null && entry.source !== null && entry.want > entry.edge;
381
+ if (entry.media === "model" || !(entry.refs > 0 || warm.has(key)) || !entry.texture || !entry.source || entry.want <= entry.edge) return false;
382
+ return entry.media === "image" || frameUrl(entry.source, entry.want, entry.natural) !== entry.url;
309
383
  }
310
384
  function refine(key, entry) {
311
- if (refinable(key, entry) && !entry.loading && entry.source) enqueue(key, "image", textureLoad("image", entry.source));
385
+ if (entry.loading || !entry.source || entry.media === "model") return;
386
+ if (refinable(key, entry)) enqueue(key, slotOf(entry.media, entry.source, entry.frameless), textureLoad(entry.media, entry.source));
387
+ else if (entry.media === "video" && entry.natural === void 0 && entry.texture && boundEdge(entry.texture, false) < entry.want && !fitsVideoFrames(entry.source)) probeNaturalSize(key, entry, entry.source);
312
388
  }
313
389
  function enqueue(key, media, load) {
314
390
  if (queue.some((task) => task.key === key)) return;
@@ -362,55 +438,79 @@ function createBoardAssetManager(options) {
362
438
  task.load(entry).then((loaded) => done(loaded), (error) => done(null, error));
363
439
  }
364
440
  }
441
+ async function loadSized(entry, url, media, resized, slack = 2) {
442
+ const { cover, want: edge } = entry;
443
+ const pending = pendingUnloads.get(`${media}:${url}`);
444
+ if (pending) await pending;
445
+ if (disposed) return null;
446
+ const texture = await loadTexture(url, media);
447
+ if (!texture) return null;
448
+ const bound = boundEdge(texture, cover);
449
+ if (media === "image" && shrinkTexture && edge < bound / slack) {
450
+ const longEdge = Math.round(edge * Math.max(texture.width, texture.height) / bound);
451
+ const small = await shrinkTexture(texture, longEdge).catch(() => null);
452
+ if (small) {
453
+ releaseTexture(media, url, texture);
454
+ return {
455
+ texture: small,
456
+ url: "",
457
+ edge
458
+ };
459
+ }
460
+ }
461
+ return {
462
+ texture,
463
+ url,
464
+ edge: sharpEdge(texture, edge, resized, cover)
465
+ };
466
+ }
365
467
  function textureLoad(media, source) {
366
468
  return async (entry) => {
367
469
  entry.source = source;
368
470
  const { cover, want: edge } = entry;
369
- const variant = media === "image" ? variantUrl(source, Math.max(edge, SMALLEST_VARIANT), cover) : null;
370
- const url = variant ?? source;
371
- const pending = pendingUnloads.get(`${media}:${url}`);
372
- if (pending) await pending;
373
- if (disposed) return null;
374
- if (media === "image" && !variant && useSharedImagePool && Number.isFinite(edge)) {
375
- const fitted = await loadFittedImageTexture(url, edge, cover).catch(() => null);
471
+ if (media === "video") {
472
+ if (entry.natural === void 0 && !entry.frameless && mediaHost(source)?.metaHeaders) entry.natural = await factSize(source);
473
+ const frame = entry.frameless ? null : frameUrl(source, edge, entry.natural);
474
+ if (frame) {
475
+ const loaded = await loadSized(entry, frame, "image", true, FRAME_SLACK).catch(() => null);
476
+ if (loaded) {
477
+ learnAspect(source, loaded.texture);
478
+ if (!entry.natural && !fitsVideoFrames(source) && loaded.edge === Number.POSITIVE_INFINITY) loaded.edge = boundEdge(loaded.texture, false);
479
+ }
480
+ if (loaded || entry.texture) return loaded;
481
+ entry.frameless = true;
482
+ throw FRAMELESS;
483
+ }
484
+ if (options.loadVideoPreviews === false) throw NO_PREVIEW;
485
+ return loadSized(entry, source, "video", false);
486
+ }
487
+ const variant = variantUrl(source, Math.max(edge, SMALLEST_VARIANT), cover);
488
+ if (!variant && useSharedImagePool && Number.isFinite(edge)) {
489
+ const fitted = await loadFittedImageTexture(source, edge, cover).catch(() => null);
376
490
  if (fitted) return {
377
491
  texture: fitted,
378
492
  url: "",
379
493
  edge: sharpEdge(fitted, edge, true, cover)
380
494
  };
381
495
  }
382
- const texture = await loadTexture(url, media);
383
- if (!texture) return null;
384
- const bound = boundEdge(texture, cover);
385
- if (media === "image" && shrinkTexture && edge < bound / 2) {
386
- const longEdge = Math.round(edge * Math.max(texture.width, texture.height) / bound);
387
- const small = await shrinkTexture(texture, longEdge).catch(() => null);
388
- if (small) {
389
- releaseTexture(media, url, texture);
390
- return {
391
- texture: small,
392
- url: "",
393
- edge
394
- };
395
- }
396
- }
397
- return {
398
- texture,
399
- url,
400
- edge: sharpEdge(texture, edge, Boolean(variant), cover)
401
- };
496
+ return loadSized(entry, variant ?? source, "image", Boolean(variant));
402
497
  };
403
498
  }
404
- function resolveFile(key, media, path) {
499
+ function startFile(key, media, path) {
405
500
  return (entry) => {
406
501
  const fresh = entry.attempts > 0;
407
502
  entry.loading = true;
408
- resolveFileUrl(path, fresh ? { fresh } : void 0).then((url) => {
503
+ options.resolveFile(path, fresh ? { fresh } : void 0).then((file) => {
409
504
  if (disposed || entries.get(key) !== entry) return;
410
505
  if (entry.refs <= 0 && !warm.has(key)) return evict(key, entry);
411
- if (!url) return adopt(key, entry, null, /* @__PURE__ */ new Error(`Unavailable file: ${path}`));
506
+ if (!file) return adopt(key, entry, null, /* @__PURE__ */ new Error(`Unavailable file: ${path}`));
507
+ const { url, media: facts } = file;
508
+ if (facts && !entry.natural) entry.natural = {
509
+ width: facts.width,
510
+ height: facts.height
511
+ };
412
512
  entry.loading = false;
413
- enqueue(key, media, textureLoad(media, url));
513
+ enqueue(key, slotOf(media, url, entry.frameless), textureLoad(media, url));
414
514
  }, (error) => {
415
515
  if (!disposed) adopt(key, entry, null, error);
416
516
  });
@@ -418,12 +518,10 @@ function createBoardAssetManager(options) {
418
518
  }
419
519
  function probeNaturalSize(key, entry, source) {
420
520
  entry.natural = null;
421
- probeMediaInfo(source, { type: "image" }).then(({ width, height }) => {
422
- if (disposed || entries.get(key) !== entry || !width || !height) return;
423
- entry.natural = {
424
- width,
425
- height
426
- };
521
+ loadMediaSize(source, entry.media === "video" ? "video" : "image").then((size) => {
522
+ if (disposed || entries.get(key) !== entry || !size) return;
523
+ entry.natural = size;
524
+ refine(key, entry);
427
525
  notify(key);
428
526
  });
429
527
  }
@@ -457,34 +555,36 @@ function createBoardAssetManager(options) {
457
555
  return load ? () => enqueue(key, "model", load) : null;
458
556
  }
459
557
  const media = source.media;
460
- if (source.kind === "file") return resolveFile(key, media, source.value);
558
+ if (source.kind === "file") return startFile(key, media, source.value);
461
559
  const load = textureLoad(media, source.value);
462
- return () => enqueue(key, media, load);
560
+ return (entry) => enqueue(key, slotOf(media, source.value, entry.frameless), load);
463
561
  }
464
562
  const manager = {
465
563
  assetKey: managedAssetKey,
466
564
  invalidatePath(path) {
467
- if (disposed) return;
468
- const stale = [...entries.keys()].filter((key) => {
469
- const source = boardAssetKeySource(key);
470
- return source?.kind === "file" && source.value === path;
471
- });
472
- if (stale.length === 0) return;
565
+ const stale = keysByPath.get(path);
566
+ if (disposed || !stale) return;
473
567
  pathVersions.set(path, (pathVersions.get(path) ?? 0) + 1);
474
- keyEpoch += 1;
475
- for (const key of stale) notify(key);
568
+ for (const key of [...stale]) notify(key);
476
569
  },
477
- invalidateItemAssets() {
478
- if (options.itemAsset) keyEpoch += 1;
570
+ invalidateItemAssets(ids) {
571
+ if (!options.itemAsset) return;
572
+ if (!ids) {
573
+ keyEpoch += 1;
574
+ itemEpochs.clear();
575
+ return;
576
+ }
577
+ for (const id of ids) itemEpochs.set(id, (itemEpochs.get(id) ?? 0) + 1);
479
578
  },
480
579
  requestItem(item, edge = Number.POSITIVE_INFINITY) {
481
580
  if (disposed) return;
482
581
  const key = managedAssetKey(item);
483
- if (!key || failedModels.has(key)) return;
582
+ if (!key || unavailable.has(key)) return;
484
583
  const source = boardAssetKeySource(key);
485
584
  if (!source) return;
486
585
  const entry = ensureEntry(key);
487
586
  entry.lastUsedAt = now();
587
+ if (!entry.natural) entry.natural = keys.get(item)?.natural ?? entry.natural;
488
588
  entry.center = {
489
589
  x: item.frame.x + item.frame.width / 2,
490
590
  y: item.frame.y + item.frame.height / 2
@@ -659,6 +759,7 @@ function createBoardAssetManager(options) {
659
759
  },
660
760
  destroy() {
661
761
  disposed = true;
762
+ stopSource?.();
662
763
  if (trimTimer !== null) clearTimeout(trimTimer);
663
764
  trimTimer = null;
664
765
  cancelTone?.();
@@ -669,13 +770,15 @@ function createBoardAssetManager(options) {
669
770
  unwarmable.clear();
670
771
  listeners.clear();
671
772
  pathVersions.clear();
773
+ itemEpochs.clear();
672
774
  starters.clear();
673
- failedModels.clear();
775
+ unavailable.clear();
674
776
  for (const [key, entry] of entries) evict(key, entry);
675
777
  entries.clear();
676
778
  for (const release of [...retiring]) release();
677
779
  }
678
780
  };
781
+ stopSource = options.subscribe?.((path) => manager.invalidatePath(path)) ?? null;
679
782
  return manager;
680
783
  }
681
784
  //#endregion
@@ -0,0 +1,25 @@
1
+ import { BoardAssetSource } from "../model/media-playback.js";
2
+ import "../model/index.js";
3
+ import { BoardBackdrop } from "../render/backdrop.js";
4
+ import { BoardBackdropView } from "../render/backdrop-layer.js";
5
+ import "../render/index.js";
6
+ import { BoardImageTexturePool } from "./texture-pool.js";
7
+ import { Renderer } from "pixi.js";
8
+ //#region src/board/stage/backdrop.d.ts
9
+ type BoardBackdropStatus = {
10
+ src: string;
11
+ status: "loading" | "ready" | "error";
12
+ };
13
+ declare function createBoardStageBackdrop(options: {
14
+ renderer: Renderer;
15
+ source: BoardAssetSource;
16
+ pool?: BoardImageTexturePool;
17
+ requestFrame: () => void;
18
+ onStatus?: (status: BoardBackdropStatus | null) => void;
19
+ }): {
20
+ container: import("pixi.js").Container<import("pixi.js").ContainerChild>;
21
+ sync(backdrop: BoardBackdrop, view: BoardBackdropView, reducedMotion: boolean): void;
22
+ destroy(): void;
23
+ };
24
+ //#endregion
25
+ export { BoardBackdropStatus, createBoardStageBackdrop };
@@ -0,0 +1,104 @@
1
+ import { imageVariantUrl, mediaVariantSize } from "../../protocol/dist/core/media.js";
2
+ import { parseBoardMediaSource } from "../../protocol/dist/board-model.js";
3
+ import { createBoardBackdropLayer } from "../render/backdrop-layer.js";
4
+ import { boardImageTexturePool } from "./texture-pool.js";
5
+ import { RendererType } from "pixi.js";
6
+ //#region src/board/stage/backdrop.ts
7
+ const FADE_MS = 160;
8
+ const RETRY_MIN_MS = 2e3;
9
+ const RETRY_MAX_MS = 32e3;
10
+ function createBoardStageBackdrop(options) {
11
+ const layer = createBoardBackdropLayer({ patterns: options.renderer.type === RendererType.WEBGL });
12
+ const pool = options.pool ?? boardImageTexturePool;
13
+ let loadKey = null;
14
+ let file = null;
15
+ let held = null;
16
+ let texture = null;
17
+ let readyAt = 0;
18
+ let generation = 0;
19
+ let retry = null;
20
+ const reload = () => {
21
+ loadKey = null;
22
+ options.requestFrame();
23
+ };
24
+ const stopFiles = options.source.subscribe?.((path) => {
25
+ if (path === file) reload();
26
+ });
27
+ function retryLater(key) {
28
+ const delay = retry?.key === key ? retry.delay * 2 : RETRY_MIN_MS;
29
+ stopRetry();
30
+ if (delay <= RETRY_MAX_MS) retry = {
31
+ key,
32
+ delay,
33
+ timer: setTimeout(reload, delay)
34
+ };
35
+ }
36
+ function stopRetry() {
37
+ if (retry) clearTimeout(retry.timer);
38
+ retry = null;
39
+ }
40
+ function show(key, next) {
41
+ if (held) pool.release(held);
42
+ if (next && !texture) readyAt = performance.now();
43
+ held = key;
44
+ texture = next;
45
+ }
46
+ function edgeFor(image, view) {
47
+ if (image.fit !== "repeat") return Math.max(view.width, view.height) * view.density;
48
+ return image.tile ? Math.max(image.tile.width, image.tile.height) * view.density : Number.POSITIVE_INFINITY;
49
+ }
50
+ async function load(image, edge, current, attempt) {
51
+ const report = (status) => {
52
+ if (current === generation) options.onStatus?.({
53
+ src: image.src,
54
+ status
55
+ });
56
+ };
57
+ report("loading");
58
+ const source = parseBoardMediaSource(image.src);
59
+ file = source?.kind === "file" ? source.value : null;
60
+ const url = file ? await options.source.resolveFile(file).then((resolved) => resolved?.url ?? null, () => null) : image.src;
61
+ if (current !== generation) return;
62
+ const fit = image.fit === "cover" ? "cover" : "contain";
63
+ const key = url && (edge <= 1536 && imageVariantUrl(url, mediaVariantSize(edge), fit) || url);
64
+ const loaded = key ? await pool.acquire(key).catch(() => null) : null;
65
+ if (current !== generation) {
66
+ if (key && loaded) pool.release(key);
67
+ return;
68
+ }
69
+ show(loaded ? key : null, loaded);
70
+ report(loaded ? "ready" : "error");
71
+ if (loaded) stopRetry();
72
+ else retryLater(attempt);
73
+ options.requestFrame();
74
+ }
75
+ return {
76
+ container: layer.container,
77
+ sync(backdrop, view, reducedMotion) {
78
+ const next = backdrop.image ? `${backdrop.image.fit} ${backdrop.image.src}` : null;
79
+ if (next !== loadKey) {
80
+ loadKey = next;
81
+ generation += 1;
82
+ if (retry && retry.key !== next) stopRetry();
83
+ if (backdrop.image && next) load(backdrop.image, edgeFor(backdrop.image, view), generation, next);
84
+ else {
85
+ file = null;
86
+ show(null, null);
87
+ options.onStatus?.(null);
88
+ }
89
+ }
90
+ const fade = reducedMotion ? 1 : Math.min(1, (performance.now() - readyAt) / FADE_MS);
91
+ layer.sync(backdrop, view, texture, fade);
92
+ if (texture && fade < 1) options.requestFrame();
93
+ },
94
+ destroy() {
95
+ generation += 1;
96
+ stopRetry();
97
+ stopFiles?.();
98
+ show(null, null);
99
+ layer.destroy();
100
+ }
101
+ };
102
+ }
103
+ //#endregion
104
+ export { createBoardStageBackdrop };
@@ -1,6 +1,7 @@
1
1
  import { COMPACT_LRU_BUDGET, DEFAULT_LRU_BUDGET, LruBudget, LruEntry, LruTrim, planLruTrim, selectLruEvictions } from "./lru.js";
2
2
  import { BoardImageTexturePool, boardImageTexturePool, createBoardImageTexturePool, loadBoardImageTexture, loadFittedImageTexture, readTextureTone, shrinkBoardTexture, unloadBoardImageTexture } from "./texture-pool.js";
3
3
  import { BoardAssetChange, BoardAssetManager, BoardAssetManagerOptions, BoardAssetPrefetch, BoardModelPreviewRequest, BoardModelPreviews, createBoardAssetManager } from "./assets.js";
4
+ import { BoardBackdropStatus } from "./backdrop.js";
4
5
  import { BOARD_LONG_PRESS_MS, BoardStagePress } from "./presses.js";
5
6
  import { BoardFrameBudget, BoardFrameWork } from "./frame-budget.js";
6
7
  import { BoardLiveStroke, BoardScene, BoardSceneNode, SceneSyncInput, createBoardScene } from "./scene.js";
@@ -11,4 +12,4 @@ import { BOARD_DEVICE_TIERS, BoardDeviceBudget, BoardDeviceEnvironment, BoardDev
11
12
  import { toBoardPointerEvent } from "./pointer.js";
12
13
  import { createBoardPointerGestures } from "./pointer-gestures.js";
13
14
  import { BoardOverlayInput, drawBoardOverlay, expandFrame } from "./overlay.js";
14
- export { BOARD_BENCH_PHASES, BOARD_DEVICE_TIERS, BOARD_LONG_PRESS_MS, BoardAssetChange, BoardAssetManager, BoardAssetManagerOptions, BoardAssetPrefetch, BoardBenchPercentiles, BoardBenchPhase, BoardBenchPhaseReport, BoardBenchReport, BoardBenchSettle, BoardDeviceBudget, BoardDeviceEnvironment, BoardDeviceSignals, BoardDeviceTier, type BoardFrameBudget, type BoardFrameWork, BoardImageTexturePool, BoardItemView, BoardItemViewContext, BoardItemViewInstance, BoardItemViewLayer, BoardLiveStroke, BoardModelPreviewRequest, BoardModelPreviews, BoardOverlayInput, BoardScene, BoardSceneNode, BoardStage, BoardStageOptions, BoardStagePointerPhase, type BoardStagePress, BoardStageStats, BoardStageTheme, COMPACT_LRU_BUDGET, DEFAULT_LRU_BUDGET, LruBudget, LruEntry, LruTrim, SceneSyncInput, boardDeviceBudget, boardDeviceTier, boardImageTexturePool, createBoardAssetManager, createBoardImageTexturePool, createBoardItemViewLayer, createBoardPointerGestures, createBoardScene, defaultBoardDeviceBudget, drawBoardOverlay, expandFrame, loadBoardImageTexture, loadFittedImageTexture, mountBoardStage, planLruTrim, readBoardDeviceSignals, readTextureTone, runBoardStageBench, selectLruEvictions, shrinkBoardTexture, toBoardPointerEvent, unloadBoardImageTexture };
15
+ export { BOARD_BENCH_PHASES, BOARD_DEVICE_TIERS, BOARD_LONG_PRESS_MS, BoardAssetChange, BoardAssetManager, BoardAssetManagerOptions, BoardAssetPrefetch, type BoardBackdropStatus, BoardBenchPercentiles, BoardBenchPhase, BoardBenchPhaseReport, BoardBenchReport, BoardBenchSettle, BoardDeviceBudget, BoardDeviceEnvironment, BoardDeviceSignals, BoardDeviceTier, type BoardFrameBudget, type BoardFrameWork, BoardImageTexturePool, BoardItemView, BoardItemViewContext, BoardItemViewInstance, BoardItemViewLayer, BoardLiveStroke, BoardModelPreviewRequest, BoardModelPreviews, BoardOverlayInput, BoardScene, BoardSceneNode, BoardStage, BoardStageOptions, BoardStagePointerPhase, type BoardStagePress, BoardStageStats, BoardStageTheme, COMPACT_LRU_BUDGET, DEFAULT_LRU_BUDGET, LruBudget, LruEntry, LruTrim, SceneSyncInput, boardDeviceBudget, boardDeviceTier, boardImageTexturePool, createBoardAssetManager, createBoardImageTexturePool, createBoardItemViewLayer, createBoardPointerGestures, createBoardScene, defaultBoardDeviceBudget, drawBoardOverlay, expandFrame, loadBoardImageTexture, loadFittedImageTexture, mountBoardStage, planLruTrim, readBoardDeviceSignals, readTextureTone, runBoardStageBench, selectLruEvictions, shrinkBoardTexture, toBoardPointerEvent, unloadBoardImageTexture };
@@ -1,9 +1,9 @@
1
1
  import { parseBoardItem } from "../../protocol/dist/board-model.js";
2
2
  import { drawPointsBounds } from "../../protocol/dist/board-layout.js";
3
3
  import { itemBounds, rectsIntersect } from "../model/geometry.js";
4
- import { DEFAULT_BOARD_LOD, boardCardDetailed, boardLodRatio, boardLodSize } from "../render/lod.js";
5
4
  import { textZoomBucket } from "../render/text-resolution.js";
6
5
  import { clippingAncestor, isClippingFrame, syncClipGroup } from "../render/clip.js";
6
+ import { DEFAULT_BOARD_LOD, boardCardDetailed, boardLodRatio, boardLodSize } from "../render/lod.js";
7
7
  import { fixedBoardFrameBudget } from "./frame-budget.js";
8
8
  import { Graphics, Text } from "pixi.js";
9
9
  //#region src/board/stage/scene.ts
@@ -14,6 +14,7 @@ const Z_LIVE_STROKE = Number.MAX_SAFE_INTEGER - 1;
14
14
  const Z_OVERLAY = Number.MAX_SAFE_INTEGER;
15
15
  const POOL_LIMIT_PER_RENDERER = 48;
16
16
  const EMPTY_IDS = /* @__PURE__ */ new Set();
17
+ const NO_PLATES = /* @__PURE__ */ new Map();
17
18
  function idSetSignature(ids) {
18
19
  let hash = ids.size;
19
20
  for (const id of ids) {
@@ -257,13 +258,19 @@ function createBoardScene(options) {
257
258
  let paintedPlan = null;
258
259
  let paintedSig = "";
259
260
  let platesPending = false;
260
- let covered = EMPTY_IDS;
261
+ let covered = NO_PLATES;
261
262
  /**
262
263
  * Repaint the plates whose cards changed, within a per-frame budget. A card
263
264
  * is covered once a plate draws it; until then it stays live if it already is.
264
265
  */
265
266
  function paintPlates(input, current) {
266
267
  const { context, globalSig, scene } = input;
268
+ for (const id of input.staleIds ?? EMPTY_IDS) {
269
+ const plate = covered.get(id);
270
+ if (!plate) continue;
271
+ plate.sig = "";
272
+ paintedPlan = null;
273
+ }
267
274
  if (current === paintedPlan && globalSig === paintedSig) return false;
268
275
  const start = performance.now();
269
276
  const allowance = budget.allow("plate");
@@ -303,8 +310,8 @@ function createBoardScene(options) {
303
310
  plate.graphics.destroy();
304
311
  changed = true;
305
312
  }
306
- const next = /* @__PURE__ */ new Set();
307
- for (const plate of plates.values()) for (const item of plate.items) next.add(item.id);
313
+ const next = /* @__PURE__ */ new Map();
314
+ for (const plate of plates.values()) for (const item of plate.items) next.set(item.id, plate);
308
315
  covered = next;
309
316
  budget.spend("plate", painted, paintMs);
310
317
  platesPending = pending;
@@ -328,7 +335,7 @@ function createBoardScene(options) {
328
335
  paintedPlan = null;
329
336
  paintedSig = "";
330
337
  platesPending = false;
331
- covered = EMPTY_IDS;
338
+ covered = NO_PLATES;
332
339
  plan = null;
333
340
  planInputs = [];
334
341
  nearIds = EMPTY_IDS;