@oxy.so/bloom 7.1.0 → 7.1.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.
Files changed (43) hide show
  1. package/assets/character-runtime/managed-avatar.mjs +258 -0
  2. package/assets/character-runtime/render-budget.mjs +144 -0
  3. package/assets/character-runtime/runtime.mjs +49 -18
  4. package/docs/adoption-matrix.mdx +1 -1
  5. package/docs/agent-avatar.mdx +11 -2
  6. package/lib/commonjs/agent-avatar/AgentAvatarProvider.js +8 -3
  7. package/lib/commonjs/agent-avatar/AgentAvatarProvider.js.map +1 -1
  8. package/lib/commonjs/agent-avatar/CharacterAvatar.js +3 -2
  9. package/lib/commonjs/agent-avatar/CharacterAvatar.js.map +1 -1
  10. package/lib/commonjs/agent-avatar/context.js +4 -1
  11. package/lib/commonjs/agent-avatar/context.js.map +1 -1
  12. package/lib/commonjs/agent-creator/AgentCreatorBase.js +7 -5
  13. package/lib/commonjs/agent-creator/AgentCreatorBase.js.map +1 -1
  14. package/lib/commonjs/agent-creator/CharacterControls.js +4 -2
  15. package/lib/commonjs/agent-creator/CharacterControls.js.map +1 -1
  16. package/lib/module/agent-avatar/AgentAvatarProvider.js +9 -4
  17. package/lib/module/agent-avatar/AgentAvatarProvider.js.map +1 -1
  18. package/lib/module/agent-avatar/CharacterAvatar.js +3 -2
  19. package/lib/module/agent-avatar/CharacterAvatar.js.map +1 -1
  20. package/lib/module/agent-avatar/context.js +3 -0
  21. package/lib/module/agent-avatar/context.js.map +1 -1
  22. package/lib/module/agent-creator/AgentCreatorBase.js +9 -7
  23. package/lib/module/agent-creator/AgentCreatorBase.js.map +1 -1
  24. package/lib/module/agent-creator/CharacterControls.js +5 -3
  25. package/lib/module/agent-creator/CharacterControls.js.map +1 -1
  26. package/lib/typescript/commonjs/agent-avatar/AgentAvatarProvider.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/agent-avatar/CharacterAvatar.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/agent-avatar/context.d.ts +4 -1
  29. package/lib/typescript/commonjs/agent-avatar/context.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/agent-creator/AgentCreatorBase.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/agent-creator/CharacterControls.d.ts.map +1 -1
  32. package/lib/typescript/module/agent-avatar/AgentAvatarProvider.d.ts.map +1 -1
  33. package/lib/typescript/module/agent-avatar/CharacterAvatar.d.ts.map +1 -1
  34. package/lib/typescript/module/agent-avatar/context.d.ts +4 -1
  35. package/lib/typescript/module/agent-avatar/context.d.ts.map +1 -1
  36. package/lib/typescript/module/agent-creator/AgentCreatorBase.d.ts.map +1 -1
  37. package/lib/typescript/module/agent-creator/CharacterControls.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/agent-avatar/AgentAvatarProvider.tsx +11 -4
  40. package/src/agent-avatar/CharacterAvatar.tsx +9 -4
  41. package/src/agent-avatar/context.ts +5 -1
  42. package/src/agent-creator/AgentCreatorBase.tsx +13 -5
  43. package/src/agent-creator/CharacterControls.tsx +7 -3
@@ -0,0 +1,258 @@
1
+ import { renderBudget } from './render-budget.mjs';
2
+
3
+ const snapshots = new Map();
4
+ const waiting = new Map();
5
+ const pixelsFor = (canvas) =>
6
+ Math.max(
7
+ 64,
8
+ Math.min(
9
+ 512,
10
+ Math.round(
11
+ Math.min(canvas.clientWidth, canvas.clientHeight) *
12
+ Math.min(devicePixelRatio || 1, 2),
13
+ ),
14
+ ),
15
+ );
16
+
17
+ // Presentation lifetime is independent of the GPU lease. Overflow avatars keep
18
+ // their real engine pixels and can reclaim a lease through pointer/command input.
19
+ export function createManagedAvatar(canvas, initial, callbacks, factory) {
20
+ let props = initial,
21
+ controller,
22
+ disposed = false,
23
+ ready = false;
24
+ let visible = false,
25
+ hovered = false,
26
+ urgentUntil = 0,
27
+ timer;
28
+ let pendingWork = false,
29
+ pendingReaction = false,
30
+ starting = false;
31
+ let capabilities,
32
+ lastDiagnostics = {},
33
+ cacheKey = '',
34
+ cachedKey = '';
35
+ const still = () =>
36
+ props.portrait ||
37
+ props.paused ||
38
+ props.reduced ||
39
+ props.config.motion === 0;
40
+ const fingerprint = () =>
41
+ JSON.stringify([
42
+ props.config.character,
43
+ props.legacy,
44
+ props.config.lookAt,
45
+ pixelsFor(canvas),
46
+ ]);
47
+ const priority = () =>
48
+ pendingWork || pendingReaction || performance.now() < urgentUntil
49
+ ? 3
50
+ : hovered
51
+ ? 2
52
+ : props.interactive
53
+ ? 1
54
+ : 0;
55
+ const refresh = () =>
56
+ lease.update({
57
+ visible: (visible || props.portrait) && !document.hidden,
58
+ still: still(),
59
+ priority: still() ? 0 : priority(),
60
+ needsPaint: !ready,
61
+ });
62
+ function promote(duration = 5000) {
63
+ if (still()) return;
64
+ urgentUntil = performance.now() + duration;
65
+ clearTimeout(timer);
66
+ timer = setTimeout(refresh, duration + 1);
67
+ refresh();
68
+ }
69
+ function commands() {
70
+ if (!controller?.diagnostics?.()?.ready) return;
71
+ if (pendingWork)
72
+ promote(
73
+ 2500 * Math.max(1, Math.min(10, props.workingCycles || 1)) + 1000,
74
+ );
75
+ else if (pendingReaction) promote();
76
+ if (pendingWork) {
77
+ pendingWork = false;
78
+ controller.work?.();
79
+ }
80
+ if (pendingReaction) {
81
+ pendingReaction = false;
82
+ controller.react?.();
83
+ }
84
+ }
85
+ const lease = renderBudget.register({
86
+ async start() {
87
+ starting = true;
88
+ try {
89
+ const created = await factory(canvas, props, {
90
+ onCapabilities(value) {
91
+ if (disposed) return;
92
+ capabilities = value;
93
+ callbacks.onCapabilities?.(value);
94
+ },
95
+ onError(error) {
96
+ if (!disposed) lease.fail(error);
97
+ },
98
+ onReady() {
99
+ if (!disposed) queueMicrotask(commands);
100
+ },
101
+ onPaint(settled = true) {
102
+ if (disposed || !settled) return;
103
+ if (!ready) {
104
+ ready = true;
105
+ lease.painted();
106
+ callbacks.onReady?.();
107
+ }
108
+ if (cachedKey !== cacheKey) {
109
+ const image = document.createElement('canvas');
110
+ image.width = canvas.width;
111
+ image.height = canvas.height;
112
+ image.getContext('2d').drawImage(canvas, 0, 0);
113
+ snapshots.delete(cacheKey);
114
+ snapshots.set(cacheKey, { image, capabilities });
115
+ if (snapshots.size > 64)
116
+ snapshots.delete(snapshots.keys().next().value);
117
+ cachedKey = cacheKey;
118
+ for (const receive of waiting.get(cacheKey) ?? []) receive();
119
+ }
120
+ callbacks.onPaint?.();
121
+ },
122
+ });
123
+ controller = created;
124
+ created.update(props, true);
125
+ return created;
126
+ } finally {
127
+ starting = false;
128
+ }
129
+ },
130
+ suspend(value) {
131
+ lastDiagnostics = value.diagnostics?.() ?? lastDiagnostics;
132
+ value.dispose();
133
+ if (controller === value) controller = undefined;
134
+ },
135
+ error(error) {
136
+ if (!disposed) callbacks.onError?.(error);
137
+ },
138
+ });
139
+ function paintSnapshot() {
140
+ if (disposed || ready || controller || starting) return;
141
+ const saved = snapshots.get(cacheKey);
142
+ if (!saved) return;
143
+ canvas.width = saved.image.width;
144
+ canvas.height = saved.image.height;
145
+ canvas.getContext('2d').drawImage(saved.image, 0, 0);
146
+ ready = true;
147
+ cachedKey = cacheKey;
148
+ capabilities = saved.capabilities;
149
+ callbacks.onCapabilities?.(capabilities);
150
+ callbacks.onReady?.();
151
+ callbacks.onPaint?.();
152
+ refresh();
153
+ }
154
+ function unwatch() {
155
+ const watchers = waiting.get(cacheKey);
156
+ watchers?.delete(paintSnapshot);
157
+ if (watchers?.size === 0) waiting.delete(cacheKey);
158
+ }
159
+ function appearance() {
160
+ const next = fingerprint();
161
+ if (next === cacheKey) return;
162
+ unwatch();
163
+ cacheKey = next;
164
+ ready = false;
165
+ if (!waiting.has(cacheKey)) waiting.set(cacheKey, new Set());
166
+ waiting.get(cacheKey).add(paintSnapshot);
167
+ paintSnapshot();
168
+ }
169
+ const update = (next) => {
170
+ if (disposed) return;
171
+ const work =
172
+ next.workingKey !== undefined && next.workingKey !== props.workingKey;
173
+ const reaction =
174
+ next.reactionKey !== undefined && next.reactionKey !== props.reactionKey;
175
+ props = next;
176
+ if (still()) {
177
+ pendingWork = false;
178
+ pendingReaction = false;
179
+ }
180
+ appearance();
181
+ const queueCommands = !controller?.diagnostics?.()?.ready;
182
+ if (!still() && (work || reaction)) {
183
+ if (queueCommands) {
184
+ pendingWork ||= work;
185
+ pendingReaction ||= reaction;
186
+ }
187
+ promote(
188
+ work
189
+ ? 2500 * Math.max(1, Math.min(10, props.workingCycles || 1)) + 5000
190
+ : 5000,
191
+ );
192
+ }
193
+ controller?.update(props, queueCommands);
194
+ refresh();
195
+ };
196
+ const enter = () => {
197
+ if (props.interactive) {
198
+ hovered = true;
199
+ refresh();
200
+ }
201
+ };
202
+ const leave = () => {
203
+ hovered = false;
204
+ refresh();
205
+ };
206
+ const down = (event) => {
207
+ if (!props.interactive || event.button !== 0 || !event.isPrimary || still())
208
+ return;
209
+ // Cold surfaces cannot receive the original pointer-down yet. Replay one
210
+ // original reaction once ready; warm surfaces receive their normal events.
211
+ if (!controller || !controller.diagnostics?.()?.ready)
212
+ pendingReaction = true;
213
+ promote();
214
+ };
215
+ canvas.addEventListener('pointerenter', enter);
216
+ canvas.addEventListener('pointerleave', leave);
217
+ canvas.addEventListener('pointerdown', down, true);
218
+ const intersection = new IntersectionObserver((entries) => {
219
+ visible = entries[0]?.isIntersecting === true;
220
+ refresh();
221
+ });
222
+ intersection.observe(canvas);
223
+ const resize = new ResizeObserver(() => {
224
+ appearance();
225
+ refresh();
226
+ });
227
+ resize.observe(canvas);
228
+ document.addEventListener('visibilitychange', refresh);
229
+ appearance();
230
+ refresh();
231
+ return {
232
+ update,
233
+ diagnostics: () => ({
234
+ ...lastDiagnostics,
235
+ ...controller?.diagnostics?.(),
236
+ ready,
237
+ pending: !ready || !!controller?.diagnostics?.()?.pending,
238
+ suspended: !controller,
239
+ }),
240
+ dispose() {
241
+ if (disposed) return;
242
+ disposed = true;
243
+ clearTimeout(timer);
244
+ unwatch();
245
+ intersection.disconnect();
246
+ resize.disconnect();
247
+ canvas.removeEventListener('pointerenter', enter);
248
+ canvas.removeEventListener('pointerleave', leave);
249
+ canvas.removeEventListener('pointerdown', down, true);
250
+ document.removeEventListener('visibilitychange', refresh);
251
+ lease.dispose();
252
+ },
253
+ };
254
+ }
255
+ export const managedAvatarStats = () => ({
256
+ ...renderBudget.stats(),
257
+ snapshots: snapshots.size,
258
+ });
@@ -0,0 +1,144 @@
1
+ // One budget per document, including separately imported/versioned adapters.
2
+ // Renderers retain their painted 2D canvas when their GPU lease is released.
3
+ const key = Symbol.for('bloom.character.render-budget');
4
+ export function createRenderBudget(
5
+ limit = 4,
6
+ schedule = (fn) => requestAnimationFrame(fn),
7
+ ) {
8
+ const entries = new Set();
9
+ let scheduled = false,
10
+ serial = 0,
11
+ peak = 0;
12
+ const resident = (entry) => entry.starting || entry.controller;
13
+ function wake() {
14
+ if (scheduled) return;
15
+ scheduled = true;
16
+ schedule(pump);
17
+ }
18
+ function stop(entry) {
19
+ entry.wanted = false;
20
+ if (entry.controller) {
21
+ const controller = entry.controller;
22
+ entry.controller = null;
23
+ entry.suspend(controller);
24
+ }
25
+ }
26
+ async function start(entry) {
27
+ entry.starting = true;
28
+ peak = Math.max(peak, [...entries].filter(resident).length);
29
+ try {
30
+ const controller = await entry.start();
31
+ if (!entry.wanted || entry.removed) entry.suspend(controller);
32
+ else entry.controller = controller;
33
+ } catch (error) {
34
+ entry.failed = true;
35
+ entry.error(error);
36
+ } finally {
37
+ entry.starting = false;
38
+ if (entry.removed) entries.delete(entry);
39
+ wake();
40
+ }
41
+ }
42
+ function pump() {
43
+ scheduled = false;
44
+ const eligible = [...entries].filter(
45
+ (e) => !e.removed && !e.failed && e.visible,
46
+ );
47
+ const selected = new Set();
48
+ // Explicit work/touch takes precedence. A single preparing surface keeps
49
+ // large lists from constructing dozens of native renderers in one task.
50
+ const ordered = eligible.sort(
51
+ (a, b) => b.priority - a.priority || b.order - a.order,
52
+ );
53
+ let preparing = [...entries].some((e) => resident(e) && e.needsPaint);
54
+ for (const e of ordered.filter((e) => e.priority >= 2)) {
55
+ if (selected.size === limit) break;
56
+ if (e.needsPaint && !resident(e) && preparing) continue;
57
+ selected.add(e);
58
+ if (e.needsPaint) preparing = true;
59
+ }
60
+ const pending = eligible
61
+ .filter((e) => e.needsPaint && !selected.has(e))
62
+ .sort(
63
+ (a, b) =>
64
+ Number(!!resident(b)) - Number(!!resident(a)) || a.order - b.order,
65
+ );
66
+ if (selected.size < limit && pending.length) {
67
+ const next =
68
+ pending.find((e) => resident(e)) ?? (!preparing ? pending[0] : null);
69
+ if (next) selected.add(next);
70
+ }
71
+ for (const e of ordered
72
+ .filter((e) => !e.needsPaint && !e.still)
73
+ .sort(
74
+ (a, b) =>
75
+ b.priority - a.priority ||
76
+ Number(!!resident(b)) - Number(!!resident(a)) ||
77
+ b.order - a.order,
78
+ )) {
79
+ if (selected.size === limit) break;
80
+ selected.add(e);
81
+ }
82
+ for (const e of entries) if (!selected.has(e)) stop(e);
83
+ let count = [...entries].filter(resident).length;
84
+ for (const e of selected) {
85
+ e.wanted = true;
86
+ if (!resident(e) && count < limit) {
87
+ count++;
88
+ void start(e);
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ return {
94
+ register(options) {
95
+ const entry = {
96
+ visible: false,
97
+ priority: 0,
98
+ needsPaint: true,
99
+ still: false,
100
+ starting: false,
101
+ controller: null,
102
+ wanted: false,
103
+ removed: false,
104
+ failed: false,
105
+ order: ++serial,
106
+ ...options,
107
+ };
108
+ entries.add(entry);
109
+ wake();
110
+ return {
111
+ update(value) {
112
+ Object.assign(entry, value);
113
+ if (value.priority >= 2) entry.order = ++serial;
114
+ wake();
115
+ },
116
+ painted() {
117
+ if (entry.needsPaint) {
118
+ entry.needsPaint = false;
119
+ wake();
120
+ }
121
+ },
122
+ fail(error) {
123
+ entry.failed = true;
124
+ stop(entry);
125
+ entry.error(error);
126
+ wake();
127
+ },
128
+ dispose() {
129
+ entry.removed = true;
130
+ stop(entry);
131
+ if (!entry.starting) entries.delete(entry);
132
+ wake();
133
+ },
134
+ };
135
+ },
136
+ stats: () => ({
137
+ limit,
138
+ resident: [...entries].filter(resident).length,
139
+ registered: [...entries].filter((e) => !e.removed).length,
140
+ peak,
141
+ }),
142
+ };
143
+ }
144
+ export const renderBudget = (globalThis[key] ??= createRenderBudget());
@@ -2,11 +2,14 @@
2
2
  import createModule from './orbit-characters.mjs';
3
3
  import { customizeAppearance, encodeAppearance } from './appearance-codec.mjs';
4
4
  import { acquireLegacyEngine, legacyEngineStats } from './legacy-engine.mjs';
5
+ import { createManagedAvatar, managedAvatarStats } from './managed-avatar.mjs';
5
6
 
6
7
  let modulePromise;
7
8
  let serial = 0;
8
9
  let frame = 0;
9
10
  let lastFrame = -Infinity;
11
+ let nextClient = 0;
12
+ let lastRenderMilliseconds = 0;
10
13
  const clients = new Set();
11
14
  const category = { shape: 0, color: 1, eyes: 2, eyewear: 3, accessory: 4 };
12
15
  function engine() {
@@ -26,7 +29,18 @@ function tick(time) {
26
29
  if (document.hidden) return;
27
30
  if (time - lastFrame >= 1000 / 30 - 1) {
28
31
  lastFrame = time;
29
- for (const client of [...clients]) client.render(time / 1000);
32
+ const batch = [...clients];
33
+ const started = performance.now();
34
+ for (let i = 0; i < batch.length; i++) {
35
+ const index = nextClient % batch.length;
36
+ nextClient = (index + 1) % batch.length;
37
+ const client = batch[index];
38
+ if (clients.has(client)) client.render(time / 1000);
39
+ // One native render cannot be interrupted, but a crowded page must not
40
+ // submit every remaining avatar after it has spent its frame allowance.
41
+ if (performance.now() - started >= 8) break;
42
+ }
43
+ lastRenderMilliseconds = performance.now() - started;
30
44
  }
31
45
  schedule();
32
46
  }
@@ -41,8 +55,12 @@ export async function createAvatar(canvas, initial, callbacks = {}) {
41
55
  if (!initial.config.character && !initial.legacy)
42
56
  throw new Error('A recovered character recipe is required');
43
57
  if (initial.portrait) return createPortrait(canvas, initial, callbacks);
44
- if (initial.legacy) return createLegacyCharacter(canvas, initial, callbacks);
45
- return createLiveCharacter(canvas, initial, callbacks);
58
+ return createManagedAvatar(canvas, initial, callbacks, createCharacter);
59
+ }
60
+ function createCharacter(canvas, initial, callbacks) {
61
+ return initial.legacy
62
+ ? createLegacyCharacter(canvas, initial, callbacks)
63
+ : createLiveCharacter(canvas, initial, callbacks);
46
64
  }
47
65
 
48
66
  async function createLiveCharacter(
@@ -224,14 +242,15 @@ async function createLiveCharacter(
224
242
  submitted &&
225
243
  fit &&
226
244
  !character.preparationStats().pending &&
227
- !character.hasPendingUpdate()
245
+ !character.hasPendingUpdate() &&
246
+ fingerprint === pendingFingerprint
228
247
  ) {
229
248
  dirty = false;
230
249
  if (!ready) {
231
250
  ready = true;
232
251
  callbacks.onReady?.();
233
252
  }
234
- callbacks.onPaint?.();
253
+ callbacks.onPaint?.(!transitionImage);
235
254
  if (activityMode && workDuration && !workTimer) {
236
255
  workTimer = setTimeout(() => {
237
256
  workTimer = undefined;
@@ -313,6 +332,14 @@ async function createLiveCharacter(
313
332
  dirty = true;
314
333
  wake();
315
334
  };
335
+ const work = () => {
336
+ if (!animate()) return;
337
+ clearTimeout(workTimer);
338
+ switchMode(true);
339
+ play(1);
340
+ workTimer = undefined;
341
+ workDuration = 2200 * Math.max(1, Math.min(10, props.workingCycles || 1));
342
+ };
316
343
  let appearanceTimer, pendingFingerprint;
317
344
  const applyAppearance = (value) => {
318
345
  const recipe = value;
@@ -415,7 +442,7 @@ async function createLiveCharacter(
415
442
  dirty = true;
416
443
  }
417
444
  };
418
- const update = (next) => {
445
+ const update = (next, silent = false) => {
419
446
  if (disposed) return;
420
447
  props = next;
421
448
  const recipe = props.legacy
@@ -456,19 +483,16 @@ async function createLiveCharacter(
456
483
  character.setReducedMotion(false);
457
484
  if (
458
485
  hasWork &&
486
+ !silent &&
459
487
  previousWork !== props.workingKey &&
460
488
  props.workingKey !== undefined &&
461
489
  animate()
462
490
  ) {
463
- clearTimeout(workTimer);
464
- switchMode(true);
465
- play(1);
466
- workTimer = undefined;
467
- // Count visible activity time, not time spent preparing its scene.
468
- workDuration = 2200 * Math.max(1, Math.min(10, props.workingCycles || 1));
491
+ work();
469
492
  }
470
493
  if (
471
494
  hasWork &&
495
+ !silent &&
472
496
  previousReaction !== props.reactionKey &&
473
497
  props.reactionKey !== undefined
474
498
  )
@@ -597,6 +621,8 @@ async function createLiveCharacter(
597
621
  return {
598
622
  update,
599
623
  dispose,
624
+ react,
625
+ work,
600
626
  diagnostics: () => ({
601
627
  lastReaction,
602
628
  lastReactionKind,
@@ -619,12 +645,12 @@ async function createLegacyCharacter(canvas, initial, callbacks) {
619
645
  revision = 0,
620
646
  disposed = false,
621
647
  latest = initial;
622
- const update = async (next) => {
648
+ const update = async (next, silent = false) => {
623
649
  if (disposed) return;
624
650
  latest = next;
625
651
  const key = JSON.stringify(next.legacy.points ?? null);
626
652
  if (shapeKey === key) {
627
- controller?.update(next);
653
+ controller?.update(next, silent);
628
654
  return;
629
655
  }
630
656
  const snapshot = controller?.diagnostics().ready
@@ -661,7 +687,7 @@ async function createLegacyCharacter(canvas, initial, callbacks) {
661
687
  return;
662
688
  }
663
689
  controller = created;
664
- controller.update(latest);
690
+ controller.update(latest, true);
665
691
  } catch (error) {
666
692
  if (!current()) return;
667
693
  shapeKey = undefined;
@@ -670,8 +696,8 @@ async function createLegacyCharacter(canvas, initial, callbacks) {
670
696
  };
671
697
  await update(initial);
672
698
  return {
673
- update(next) {
674
- void update(next).catch((error) => {
699
+ update(next, silent = false) {
700
+ void update(next, silent).catch((error) => {
675
701
  if (!disposed) callbacks.onError?.(error);
676
702
  });
677
703
  },
@@ -680,6 +706,8 @@ async function createLegacyCharacter(canvas, initial, callbacks) {
680
706
  ++revision;
681
707
  controller?.dispose();
682
708
  },
709
+ react: () => controller?.react(),
710
+ work: () => controller?.work(),
683
711
  diagnostics: () => controller?.diagnostics(),
684
712
  };
685
713
  }
@@ -746,7 +774,7 @@ function createPortrait(canvas, initial, callbacks) {
746
774
  };
747
775
  cancel = finish;
748
776
  try {
749
- controller = await createLiveCharacter(
777
+ controller = await createManagedAvatar(
750
778
  canvas,
751
779
  { ...props, paused: true, interactive: false },
752
780
  {
@@ -772,6 +800,7 @@ function createPortrait(canvas, initial, callbacks) {
772
800
  queueMicrotask(finish);
773
801
  },
774
802
  },
803
+ createCharacter,
775
804
  );
776
805
  if (finished || !current()) {
777
806
  controller.dispose();
@@ -811,6 +840,8 @@ const registryKey = Symbol.for('bloom.character.runtimes');
811
840
  /** Diagnostics are read on demand; no DOM/debug updates run on the animation clock. */
812
841
  export function runtimeStats() {
813
842
  return {
843
+ renderMilliseconds: lastRenderMilliseconds,
844
+ budget: managedAvatarStats(),
814
845
  instances: instances.size,
815
846
  active: clients.size,
816
847
  scheduled: !!frame,
@@ -113,7 +113,7 @@ The classification is an EQUALITY in `src/__tests__/adoption-matrix.test.ts`, no
113
113
  | `activity-heatmap` | `@oxy.so/bloom/activity-heatmap` + barrel | universal | `activity-heatmap.mdx` | yes | 1 | | | | |
114
114
  | `address` | `@oxy.so/bloom/address` + barrel | universal | `address.mdx` | yes | 2 | n/a¹ | | | |
115
115
  | `admonition` | `@oxy.so/bloom/admonition` + barrel | universal | `admonition.mdx` | yes | 1 | | | | |
116
- | `agent-avatar` | `@oxy.so/bloom/agent-avatar` + barrel | universal | `agent-avatar.mdx` | yes | 14 | | | | |
116
+ | `agent-avatar` | `@oxy.so/bloom/agent-avatar` + barrel | universal | `agent-avatar.mdx` | yes | 15 | | | | |
117
117
  | `agent-chat` | `@oxy.so/bloom/agent-chat` + barrel | web fork | `agent-chat.mdx` | yes | 3 | n/a¹ | | | reads |
118
118
  | `agent-creator` | `@oxy.so/bloom/agent-creator` + barrel | web fork | `agent-creator.mdx` | yes | 5 | reads | | | |
119
119
  | `agent-limits-card` | `@oxy.so/bloom/agent-limits-card` + barrel | universal | `agent-limits-card.mdx` | yes | 2 | | | | reads |
@@ -38,12 +38,21 @@ Alien avatars have been removed from the available catalog. Saved aliens, mascot
38
38
 
39
39
  `paused`, reduced motion and `motion: 0` stop animation after a complete appearance frame. `portrait` prepares a static thumbnail. The beta can report readiness while drawing transparency if initialized with its reduced-motion flag; the adapter waits for painted pixels and then freezes its clock instead.
40
40
 
41
- Web shares the beta engine and one animation clock per document. Different migrated contours isolate their rendering modules because the recovered engine caches geometry by its original appearance key; they share one preparation worker. Unused contour modules are released. Animated characters each need a graphics surface. Use `portrait` for galleries and selectors. Static thumbnail preparation is serialized, retains a bounded cache and releases its graphics surface after painting. Offscreen/background avatars pause; unmount releases listeners and rendering resources.
41
+ Web shares the beta engine and one animation clock per document, with a shared budget of **four live renderers** across original and migrated avatars. Additional avatars retain real 3D pixels from their last completed frame while their GPU renderer is released. Pointer interaction and React/Work commands prioritize reclaiming a renderer; overflow images remain visible while that renderer prepares. A newly visible avatar without a cached frame waits for its first rendering slot. This bounds graphics resources; the recovered engine’s synchronous first preparation can still cause a long task, especially for uncached migrated contours. Leaving the viewport or hiding the document releases the renderer lease, and returning avatars can display their retained image before resuming animation.
42
+
43
+ Different migrated contours isolate their rendering modules because the recovered engine caches geometry by its original appearance key; they share one preparation worker. Unused contour modules are released. Use `portrait` for galleries and selectors. Static preparation is serialized and the image cache is bounded to 64 entries; unmount releases listeners and rendering resources. Storybook’s **Many Avatars** story exercises 48 original/migrated avatars with per-avatar React/Work controls and document scrolling.
44
+
45
+ In the repository, run the scheduler tests and the browser budget check (with Storybook running):
46
+
47
+ ```bash
48
+ node --test scripts/test-avatar-render-budget.mjs
49
+ node scripts/verify-avatar-render-budget.mjs
50
+ ```
42
51
 
43
52
  `className` and `style` apply to the outer layout node through Bloom's NativeWind wrapper. `label` defaults to the translated “Agent avatar”; `locale` overrides the scope and `accessibilityLabel` overrides the name. Prefer a meaningful name when the avatar identifies an agent.
44
53
 
45
54
  ## Native and fallback
46
55
 
47
- The optional native bridge uses `react-native-webview` and requires an absolute runtime URL reachable by the device. Web is verified in Chrome; physical-device validation remains pending. Separate WebViews cannot share engine memory or graphics contexts.
56
+ The optional native bridge uses `react-native-webview` and requires an absolute runtime URL reachable by the device. Web is verified in Chrome; physical-device validation remains pending. Each native WebView owns a separate document, so the four-renderer web budget is **not an application-wide native limit**. Separate WebViews cannot share engine memory, graphics contexts or the web document’s viewport reclamation.
48
57
 
49
58
  An unavailable runtime or missing native peer displays the translated unavailable state. Consumers without a runtime provider retain the universal SVG renderer for procedural recipes. That renderer preserves the original contours, folds, expression springs and deterministic grain; it does not support the beta's new 3D parts.
@@ -53,12 +53,17 @@ function AgentAvatarProvider({
53
53
  }, [runtimeUrl]);
54
54
  const value = (0, _react.useMemo)(() => ({
55
55
  runtimeUrl,
56
- capabilitiesByKey,
57
56
  reportCapabilities
58
- }), [runtimeUrl, capabilitiesByKey, reportCapabilities]);
57
+ }), [runtimeUrl, reportCapabilities]);
58
+ const catalog = (0, _react.useMemo)(() => ({
59
+ capabilitiesByKey
60
+ }), [capabilitiesByKey]);
59
61
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_context.CharacterRuntimeContext.Provider, {
60
62
  value: value,
61
- children: children
63
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_context.CharacterCapabilitiesContext.Provider, {
64
+ value: catalog,
65
+ children: children
66
+ })
62
67
  });
63
68
  }
64
69
  //# sourceMappingURL=AgentAvatarProvider.js.map
@@ -1 +1 @@
1
- {"version":3,"names":["_react","require","_context","_jsxRuntime","EMPTY_CAPABILITIES","Map","sameRecord","left","right","keys","Object","length","every","key","AgentAvatarProvider","runtimeUrl","children","cache","setCapabilities","useState","entries","activeRuntime","useRef","current","capabilitiesByKey","useEffect","previous","reportCapabilities","useCallback","next","prior","known","get","available","selected","delete","set","size","value","useMemo","jsx","CharacterRuntimeContext","Provider"],"sourceRoot":"../../../src","sources":["agent-avatar/AgentAvatarProvider.tsx"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["_react","require","_context","_jsxRuntime","EMPTY_CAPABILITIES","Map","sameRecord","left","right","keys","Object","length","every","key","AgentAvatarProvider","runtimeUrl","children","cache","setCapabilities","useState","entries","activeRuntime","useRef","current","capabilitiesByKey","useEffect","previous","reportCapabilities","useCallback","next","prior","known","get","available","selected","delete","set","size","value","useMemo","catalog","jsx","CharacterRuntimeContext","Provider","CharacterCapabilitiesContext"],"sourceRoot":"../../../src","sources":["agent-avatar/AgentAvatarProvider.tsx"],"mappings":"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","ignoreList":[]}
@@ -171,9 +171,10 @@ function CharacterAvatar(props) {
171
171
  };
172
172
  }, []);
173
173
  (0, _react.useEffect)(() => setFailed(false), [runtimeUrl, config.character]);
174
+ const legacy = (0, _react.useMemo)(() => !config.character || config.character.preset === 'bloom' ? (0, _legacyRecipe.legacyRecipe)(config) : undefined, [config]);
174
175
  const value = (0, _react.useMemo)(() => ({
175
176
  config,
176
- legacy: !config.character || config.character.preset === 'bloom' ? (0, _legacyRecipe.legacyRecipe)(config) : undefined,
177
+ legacy,
177
178
  paused: paused || !active,
178
179
  portrait,
179
180
  workingKey,
@@ -182,7 +183,7 @@ function CharacterAvatar(props) {
182
183
  workingCycles,
183
184
  interactive,
184
185
  reduced
185
- }), [config, paused, portrait, active, workingKey, reactionKey, entranceKey, workingCycles, interactive, reduced]);
186
+ }), [config, legacy, paused, portrait, active, workingKey, reactionKey, entranceKey, workingCycles, interactive, reduced]);
186
187
  const fail = (0, _react.useMemo)(() => () => setFailed(true), []);
187
188
  const Renderer = _reactNative.Platform.OS === 'web' ? WebCharacter : NativeCharacter;
188
189
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_styledPrimitives.StyledView, {