@oxy.so/bloom 7.1.2 → 7.1.4

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 (236) hide show
  1. package/assets/character-runtime/README.md +114 -19
  2. package/assets/character-runtime/authored-parts.mjs +754 -0
  3. package/assets/character-runtime/catalog-parts.mjs +362 -0
  4. package/assets/character-runtime/character-recipe.mjs +247 -0
  5. package/assets/character-runtime/clippo-geometry.mjs +679 -0
  6. package/assets/character-runtime/controller-mode.mjs +208 -0
  7. package/assets/character-runtime/cyclops-geometry.mjs +180 -0
  8. package/assets/character-runtime/legacy-engine.mjs +406 -54
  9. package/assets/character-runtime/legacy-geometry.mjs +242 -85
  10. package/assets/character-runtime/legacy-worker.mjs +82 -23
  11. package/assets/character-runtime/managed-avatar.mjs +12 -17
  12. package/assets/character-runtime/migrated-contours.mjs +2067 -0
  13. package/assets/character-runtime/migrated-faces.mjs +59 -0
  14. package/assets/character-runtime/pose-transition.mjs +523 -0
  15. package/assets/character-runtime/preparation-cache.mjs +60 -0
  16. package/assets/character-runtime/render-budget.mjs +5 -3
  17. package/assets/character-runtime/render-errors.mjs +180 -0
  18. package/assets/character-runtime/resolution.mjs +10 -0
  19. package/assets/character-runtime/runtime.mjs +744 -295
  20. package/assets/character-runtime/sha256.mjs +44 -0
  21. package/assets/character-runtime/shared-programs.mjs +534 -0
  22. package/assets/character-runtime/shared-surface.mjs +490 -0
  23. package/assets/character-runtime/shared-textures.mjs +420 -0
  24. package/assets/character-runtime/thumbnails/eyes/todd.png +0 -0
  25. package/docs/agent-avatar.mdx +45 -8
  26. package/docs/agent-creator.mdx +13 -3
  27. package/lib/commonjs/agent-avatar/AgentAvatar.js +1 -1
  28. package/lib/commonjs/agent-avatar/AgentAvatar.js.map +1 -1
  29. package/lib/commonjs/agent-avatar/CharacterAvatar.js +2 -1
  30. package/lib/commonjs/agent-avatar/CharacterAvatar.js.map +1 -1
  31. package/lib/commonjs/agent-avatar/character-colors.js +19 -0
  32. package/lib/commonjs/agent-avatar/character-colors.js.map +1 -0
  33. package/lib/commonjs/agent-avatar/character-shapes.js +106 -0
  34. package/lib/commonjs/agent-avatar/character-shapes.js.map +1 -0
  35. package/lib/commonjs/agent-avatar/config-character.js +4 -0
  36. package/lib/commonjs/agent-avatar/config-character.js.map +1 -1
  37. package/lib/commonjs/agent-avatar/legacy-recipe.js +19 -6
  38. package/lib/commonjs/agent-avatar/legacy-recipe.js.map +1 -1
  39. package/lib/commonjs/agent-creator/AgentCreatorBase.js +67 -107
  40. package/lib/commonjs/agent-creator/AgentCreatorBase.js.map +1 -1
  41. package/lib/commonjs/agent-creator/CharacterControls.js +23 -5
  42. package/lib/commonjs/agent-creator/CharacterControls.js.map +1 -1
  43. package/lib/commonjs/agent-creator/ClippoArtwork.js +63 -0
  44. package/lib/commonjs/agent-creator/ClippoArtwork.js.map +1 -0
  45. package/lib/commonjs/agent-creator/CyclopsArtwork.js +45 -0
  46. package/lib/commonjs/agent-creator/CyclopsArtwork.js.map +1 -0
  47. package/lib/commonjs/agent-creator/EmotionPicker.js +8 -1
  48. package/lib/commonjs/agent-creator/EmotionPicker.js.map +1 -1
  49. package/lib/commonjs/agent-creator/ShapeSilhouette.js +8 -1
  50. package/lib/commonjs/agent-creator/ShapeSilhouette.js.map +1 -1
  51. package/lib/commonjs/agent-creator/ToddArtwork.js +29 -0
  52. package/lib/commonjs/agent-creator/ToddArtwork.js.map +1 -0
  53. package/lib/commonjs/agent-creator/constants.js +16 -17
  54. package/lib/commonjs/agent-creator/constants.js.map +1 -1
  55. package/lib/commonjs/agent-creator/messages.js +1 -1
  56. package/lib/commonjs/agent-creator/messages.js.map +1 -1
  57. package/lib/commonjs/locale/translations/ar.js +1 -1
  58. package/lib/commonjs/locale/translations/ar.js.map +1 -1
  59. package/lib/commonjs/locale/translations/bn.js +1 -1
  60. package/lib/commonjs/locale/translations/bn.js.map +1 -1
  61. package/lib/commonjs/locale/translations/ca.js +1 -1
  62. package/lib/commonjs/locale/translations/ca.js.map +1 -1
  63. package/lib/commonjs/locale/translations/de.js +1 -1
  64. package/lib/commonjs/locale/translations/de.js.map +1 -1
  65. package/lib/commonjs/locale/translations/es.js +1 -1
  66. package/lib/commonjs/locale/translations/es.js.map +1 -1
  67. package/lib/commonjs/locale/translations/fr.js +1 -1
  68. package/lib/commonjs/locale/translations/fr.js.map +1 -1
  69. package/lib/commonjs/locale/translations/hi.js +1 -1
  70. package/lib/commonjs/locale/translations/hi.js.map +1 -1
  71. package/lib/commonjs/locale/translations/id.js +1 -1
  72. package/lib/commonjs/locale/translations/id.js.map +1 -1
  73. package/lib/commonjs/locale/translations/it.js +1 -1
  74. package/lib/commonjs/locale/translations/it.js.map +1 -1
  75. package/lib/commonjs/locale/translations/ja.js +1 -1
  76. package/lib/commonjs/locale/translations/ja.js.map +1 -1
  77. package/lib/commonjs/locale/translations/pt.js +1 -1
  78. package/lib/commonjs/locale/translations/pt.js.map +1 -1
  79. package/lib/commonjs/locale/translations/ru.js +1 -1
  80. package/lib/commonjs/locale/translations/ru.js.map +1 -1
  81. package/lib/commonjs/locale/translations/tr.js +1 -1
  82. package/lib/commonjs/locale/translations/tr.js.map +1 -1
  83. package/lib/commonjs/locale/translations/zh.js +1 -1
  84. package/lib/commonjs/locale/translations/zh.js.map +1 -1
  85. package/lib/module/agent-avatar/AgentAvatar.js +1 -1
  86. package/lib/module/agent-avatar/AgentAvatar.js.map +1 -1
  87. package/lib/module/agent-avatar/CharacterAvatar.js +2 -1
  88. package/lib/module/agent-avatar/CharacterAvatar.js.map +1 -1
  89. package/lib/module/agent-avatar/character-colors.js +15 -0
  90. package/lib/module/agent-avatar/character-colors.js.map +1 -0
  91. package/lib/module/agent-avatar/character-shapes.js +97 -0
  92. package/lib/module/agent-avatar/character-shapes.js.map +1 -0
  93. package/lib/module/agent-avatar/config-character.js +4 -0
  94. package/lib/module/agent-avatar/config-character.js.map +1 -1
  95. package/lib/module/agent-avatar/legacy-recipe.js +19 -6
  96. package/lib/module/agent-avatar/legacy-recipe.js.map +1 -1
  97. package/lib/module/agent-creator/AgentCreatorBase.js +69 -109
  98. package/lib/module/agent-creator/AgentCreatorBase.js.map +1 -1
  99. package/lib/module/agent-creator/CharacterControls.js +24 -6
  100. package/lib/module/agent-creator/CharacterControls.js.map +1 -1
  101. package/lib/module/agent-creator/ClippoArtwork.js +59 -0
  102. package/lib/module/agent-creator/ClippoArtwork.js.map +1 -0
  103. package/lib/module/agent-creator/CyclopsArtwork.js +41 -0
  104. package/lib/module/agent-creator/CyclopsArtwork.js.map +1 -0
  105. package/lib/module/agent-creator/EmotionPicker.js +8 -1
  106. package/lib/module/agent-creator/EmotionPicker.js.map +1 -1
  107. package/lib/module/agent-creator/ShapeSilhouette.js +8 -1
  108. package/lib/module/agent-creator/ShapeSilhouette.js.map +1 -1
  109. package/lib/module/agent-creator/ToddArtwork.js +25 -0
  110. package/lib/module/agent-creator/ToddArtwork.js.map +1 -0
  111. package/lib/module/agent-creator/constants.js +8 -16
  112. package/lib/module/agent-creator/constants.js.map +1 -1
  113. package/lib/module/agent-creator/messages.js +1 -1
  114. package/lib/module/agent-creator/messages.js.map +1 -1
  115. package/lib/module/locale/translations/ar.js +1 -1
  116. package/lib/module/locale/translations/ar.js.map +1 -1
  117. package/lib/module/locale/translations/bn.js +1 -1
  118. package/lib/module/locale/translations/bn.js.map +1 -1
  119. package/lib/module/locale/translations/ca.js +1 -1
  120. package/lib/module/locale/translations/ca.js.map +1 -1
  121. package/lib/module/locale/translations/de.js +1 -1
  122. package/lib/module/locale/translations/de.js.map +1 -1
  123. package/lib/module/locale/translations/es.js +1 -1
  124. package/lib/module/locale/translations/es.js.map +1 -1
  125. package/lib/module/locale/translations/fr.js +1 -1
  126. package/lib/module/locale/translations/fr.js.map +1 -1
  127. package/lib/module/locale/translations/hi.js +1 -1
  128. package/lib/module/locale/translations/hi.js.map +1 -1
  129. package/lib/module/locale/translations/id.js +1 -1
  130. package/lib/module/locale/translations/id.js.map +1 -1
  131. package/lib/module/locale/translations/it.js +1 -1
  132. package/lib/module/locale/translations/it.js.map +1 -1
  133. package/lib/module/locale/translations/ja.js +1 -1
  134. package/lib/module/locale/translations/ja.js.map +1 -1
  135. package/lib/module/locale/translations/pt.js +1 -1
  136. package/lib/module/locale/translations/pt.js.map +1 -1
  137. package/lib/module/locale/translations/ru.js +1 -1
  138. package/lib/module/locale/translations/ru.js.map +1 -1
  139. package/lib/module/locale/translations/tr.js +1 -1
  140. package/lib/module/locale/translations/tr.js.map +1 -1
  141. package/lib/module/locale/translations/zh.js +1 -1
  142. package/lib/module/locale/translations/zh.js.map +1 -1
  143. package/lib/typescript/commonjs/agent-avatar/CharacterAvatar.d.ts.map +1 -1
  144. package/lib/typescript/commonjs/agent-avatar/character-colors.d.ts +13 -0
  145. package/lib/typescript/commonjs/agent-avatar/character-colors.d.ts.map +1 -0
  146. package/lib/typescript/commonjs/agent-avatar/character-shapes.d.ts +19 -0
  147. package/lib/typescript/commonjs/agent-avatar/character-shapes.d.ts.map +1 -0
  148. package/lib/typescript/commonjs/agent-avatar/config-character.d.ts +4 -0
  149. package/lib/typescript/commonjs/agent-avatar/config-character.d.ts.map +1 -1
  150. package/lib/typescript/commonjs/agent-avatar/legacy-recipe.d.ts +2 -10
  151. package/lib/typescript/commonjs/agent-avatar/legacy-recipe.d.ts.map +1 -1
  152. package/lib/typescript/commonjs/agent-creator/AgentCreatorBase.d.ts.map +1 -1
  153. package/lib/typescript/commonjs/agent-creator/CharacterControls.d.ts +2 -1
  154. package/lib/typescript/commonjs/agent-creator/CharacterControls.d.ts.map +1 -1
  155. package/lib/typescript/commonjs/agent-creator/ClippoArtwork.d.ts +6 -0
  156. package/lib/typescript/commonjs/agent-creator/ClippoArtwork.d.ts.map +1 -0
  157. package/lib/typescript/commonjs/agent-creator/CyclopsArtwork.d.ts +5 -0
  158. package/lib/typescript/commonjs/agent-creator/CyclopsArtwork.d.ts.map +1 -0
  159. package/lib/typescript/commonjs/agent-creator/EmotionPicker.d.ts +1 -0
  160. package/lib/typescript/commonjs/agent-creator/EmotionPicker.d.ts.map +1 -1
  161. package/lib/typescript/commonjs/agent-creator/ShapeSilhouette.d.ts.map +1 -1
  162. package/lib/typescript/commonjs/agent-creator/ToddArtwork.d.ts +6 -0
  163. package/lib/typescript/commonjs/agent-creator/ToddArtwork.d.ts.map +1 -0
  164. package/lib/typescript/commonjs/agent-creator/constants.d.ts +7 -15
  165. package/lib/typescript/commonjs/agent-creator/constants.d.ts.map +1 -1
  166. package/lib/typescript/commonjs/agent-creator/messages.d.ts.map +1 -1
  167. package/lib/typescript/module/agent-avatar/CharacterAvatar.d.ts.map +1 -1
  168. package/lib/typescript/module/agent-avatar/character-colors.d.ts +13 -0
  169. package/lib/typescript/module/agent-avatar/character-colors.d.ts.map +1 -0
  170. package/lib/typescript/module/agent-avatar/character-shapes.d.ts +19 -0
  171. package/lib/typescript/module/agent-avatar/character-shapes.d.ts.map +1 -0
  172. package/lib/typescript/module/agent-avatar/config-character.d.ts +4 -0
  173. package/lib/typescript/module/agent-avatar/config-character.d.ts.map +1 -1
  174. package/lib/typescript/module/agent-avatar/legacy-recipe.d.ts +2 -10
  175. package/lib/typescript/module/agent-avatar/legacy-recipe.d.ts.map +1 -1
  176. package/lib/typescript/module/agent-creator/AgentCreatorBase.d.ts.map +1 -1
  177. package/lib/typescript/module/agent-creator/CharacterControls.d.ts +2 -1
  178. package/lib/typescript/module/agent-creator/CharacterControls.d.ts.map +1 -1
  179. package/lib/typescript/module/agent-creator/ClippoArtwork.d.ts +6 -0
  180. package/lib/typescript/module/agent-creator/ClippoArtwork.d.ts.map +1 -0
  181. package/lib/typescript/module/agent-creator/CyclopsArtwork.d.ts +5 -0
  182. package/lib/typescript/module/agent-creator/CyclopsArtwork.d.ts.map +1 -0
  183. package/lib/typescript/module/agent-creator/EmotionPicker.d.ts +1 -0
  184. package/lib/typescript/module/agent-creator/EmotionPicker.d.ts.map +1 -1
  185. package/lib/typescript/module/agent-creator/ShapeSilhouette.d.ts.map +1 -1
  186. package/lib/typescript/module/agent-creator/ToddArtwork.d.ts +6 -0
  187. package/lib/typescript/module/agent-creator/ToddArtwork.d.ts.map +1 -0
  188. package/lib/typescript/module/agent-creator/constants.d.ts +7 -15
  189. package/lib/typescript/module/agent-creator/constants.d.ts.map +1 -1
  190. package/lib/typescript/module/agent-creator/messages.d.ts.map +1 -1
  191. package/package.json +1 -1
  192. package/src/agent-avatar/AgentAvatar.tsx +1 -1
  193. package/src/agent-avatar/CharacterAvatar.tsx +4 -1
  194. package/src/agent-avatar/character-colors.ts +12 -0
  195. package/src/agent-avatar/character-shapes.ts +122 -0
  196. package/src/agent-avatar/config-character.ts +39 -6
  197. package/src/agent-avatar/legacy-recipe.ts +34 -6
  198. package/src/agent-creator/AgentCreatorBase.tsx +121 -152
  199. package/src/agent-creator/CharacterControls.tsx +39 -14
  200. package/src/agent-creator/ClippoArtwork.tsx +49 -0
  201. package/src/agent-creator/CyclopsArtwork.tsx +27 -0
  202. package/src/agent-creator/EmotionPicker.tsx +55 -16
  203. package/src/agent-creator/ShapeSilhouette.tsx +16 -1
  204. package/src/agent-creator/ToddArtwork.tsx +24 -0
  205. package/src/agent-creator/constants.ts +47 -14
  206. package/src/agent-creator/messages.ts +2 -1
  207. package/src/locale/translations/ar.ts +1 -1
  208. package/src/locale/translations/bn.ts +1 -1
  209. package/src/locale/translations/ca.ts +1 -1
  210. package/src/locale/translations/de.ts +1 -1
  211. package/src/locale/translations/es.ts +1 -1
  212. package/src/locale/translations/fr.ts +1 -1
  213. package/src/locale/translations/hi.ts +1 -1
  214. package/src/locale/translations/id.ts +1 -1
  215. package/src/locale/translations/it.ts +1 -1
  216. package/src/locale/translations/ja.ts +1 -1
  217. package/src/locale/translations/pt.ts +1 -1
  218. package/src/locale/translations/ru.ts +1 -1
  219. package/src/locale/translations/tr.ts +1 -1
  220. package/src/locale/translations/zh.ts +1 -1
  221. package/src/__tests__/support/adoption-matrix.ts +0 -683
  222. package/src/__tests__/support/card-surface.ts +0 -25
  223. package/src/__tests__/support/collision-fixture-barrel.ts +0 -20
  224. package/src/__tests__/support/commerce-harness.tsx +0 -97
  225. package/src/__tests__/support/composite.ts +0 -68
  226. package/src/__tests__/support/constructed-style-sheets.ts +0 -68
  227. package/src/__tests__/support/messages-in.ts +0 -10
  228. package/src/__tests__/support/press-host.ts +0 -30
  229. package/src/__tests__/support/rendered-style.ts +0 -99
  230. package/src/__tests__/support/unread-hook-fixture.ts +0 -33
  231. package/src/__tests__/support/worklet-capture-fixture.tsx +0 -28
  232. package/src/agent-avatar/__tests__/artwork-golden.json +0 -774
  233. package/src/agent-creator/__tests__/support/character-runtime-fixture.tsx +0 -26
  234. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +0 -9346
  235. package/src/theme/__tests__/__fixtures__/tonal-glass-primary-failures.json +0 -69
  236. package/src/theme/__tests__/fixtures/color-engine-golden.json +0 -1
@@ -1,48 +1,147 @@
1
1
  // Optional renderer adapter. The generated engine and data alongside it remain unmodified.
2
- import createModule from './orbit-characters.mjs';
3
- import { customizeAppearance, encodeAppearance } from './appearance-codec.mjs';
4
- import { acquireLegacyEngine, legacyEngineStats } from './legacy-engine.mjs';
2
+ import {
3
+ customizeAppearance,
4
+ encodeAppearance,
5
+ decodeAppearance,
6
+ } from './appearance-codec.mjs';
7
+ import {
8
+ acquireLegacyEngine,
9
+ legacyEngineStats,
10
+ getCharacterEngine,
11
+ acquireCharacterPreparation,
12
+ } from './legacy-engine.mjs';
5
13
  import { createManagedAvatar, managedAvatarStats } from './managed-avatar.mjs';
14
+ import {
15
+ acquireSharedSurface,
16
+ sharedSurfaceStats,
17
+ renderSharedBatchDeferred,
18
+ waitForSharedRender,
19
+ sharedRenderPending,
20
+ } from './shared-surface.mjs';
21
+ import { characterPixels } from './resolution.mjs';
22
+ import {
23
+ characterRecipe,
24
+ authoredPartsFor,
25
+ bodySignatureFor,
26
+ bodySelectionDefaults,
27
+ NATIVE_PARTS,
28
+ ORIGINAL_PRESETS,
29
+ MIGRATED_SHAPE_IDS,
30
+ isLegacyRecipe,
31
+ withCharacterGeometry,
32
+ } from './character-recipe.mjs';
33
+ import { originalBodyColor, originalPalettePaint } from './catalog-parts.mjs';
6
34
 
7
- let modulePromise;
8
- let serial = 0;
9
35
  let frame = 0;
10
- let lastFrame = -Infinity;
36
+ let rendering = false;
11
37
  let nextClient = 0;
12
38
  let lastRenderMilliseconds = 0;
39
+ let lastRenderWaitMilliseconds = 0;
40
+ let renderCost = 8;
41
+ let attentionTurn = false;
13
42
  const clients = new Set();
14
43
  const category = { shape: 0, color: 1, eyes: 2, eyewear: 3, accessory: 4 };
15
- function engine() {
16
- return (modulePromise ??= createModule({
17
- locateFile: (file) => new URL(file, import.meta.url).href,
18
- }).catch((error) => {
19
- modulePromise = undefined;
20
- throw error;
21
- }));
22
- }
44
+ const appearanceFingerprint = (recipe) => {
45
+ const { eyeSpacing: _historicalSpacing, ...appearance } = recipe;
46
+ return JSON.stringify(appearance);
47
+ };
23
48
  function schedule() {
24
- if (!frame && clients.size && !document.hidden)
49
+ if (!frame && !rendering && clients.size && !document.hidden)
25
50
  frame = requestAnimationFrame(tick);
26
51
  }
27
- function tick(time) {
52
+ async function tick(time) {
28
53
  frame = 0;
29
54
  if (document.hidden) return;
30
- if (time - lastFrame >= 1000 / 30 - 1) {
31
- lastFrame = time;
55
+ rendering = true;
56
+ try {
32
57
  const batch = [...clients];
33
58
  const started = performance.now();
59
+ const presentations = [];
60
+ // Await the GPU without blocking the page. Bound CPU work and batch size
61
+ // separately: driver wait time must not collapse every batch to one draw.
62
+ const batchLimit = Math.min(8, Math.max(1, Math.ceil(8 / renderCost)));
63
+ let submittedAt = started,
64
+ presentationCost = 0;
65
+ const touched = [];
66
+ // Preparation is globally serialized by the recovered engine. Its owner
67
+ // must get a chance to commit and paint every frame; waiting behind every
68
+ // resident avatar makes startup latency grow quadratically with the crowd.
69
+ const preparing = batch.find((client) => client.preparing());
70
+ // Give the most recent interaction a responsive cadence, but reserve
71
+ // alternate turns for the round-robin crowd so no avatar is starved.
72
+ const attended = batch
73
+ .filter((client) => client.attention(time) > 0)
74
+ .sort((a, b) => b.attention(time) - a.attention(time))[0];
75
+ const preferred = preparing ?? (attentionTurn ? attended : undefined);
76
+ attentionTurn = !attentionTurn;
77
+ const ordered = preferred ? [preferred] : [];
34
78
  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;
79
+ const client = batch[(nextClient + i) % batch.length];
80
+ if (client !== preferred) ordered.push(client);
81
+ }
82
+ try {
83
+ await renderSharedBatchDeferred(
84
+ () => {
85
+ for (const client of ordered) {
86
+ if (client !== preferred)
87
+ nextClient = (batch.indexOf(client) + 1) % batch.length;
88
+ // Rate-limit each character, not the whole document. Crowds can use
89
+ // intervening browser frames without forcing every avatar down to 30/N.
90
+ if (
91
+ clients.has(client) &&
92
+ (client === preparing ||
93
+ time - (client.lastTick ?? -Infinity) >= 1000 / 30 - 1)
94
+ ) {
95
+ client.lastTick = time;
96
+ touched.push(client);
97
+ const present = client.render(time / 1000);
98
+ if (present) presentations.push(present);
99
+ }
100
+ // One native render cannot be interrupted, but a crowded page must not
101
+ // submit every remaining avatar after it has spent its frame allowance.
102
+ if (
103
+ presentations.length >= batchLimit ||
104
+ performance.now() - started >= 8
105
+ )
106
+ break;
107
+ }
108
+ submittedAt = performance.now();
109
+ },
110
+ () => {
111
+ const began = performance.now();
112
+ for (const present of presentations) present();
113
+ presentationCost = performance.now() - began;
114
+ },
115
+ );
116
+ } catch (error) {
117
+ // A context-level error cannot be attributed after a shared submission.
118
+ // Discard every pending image and report it to every touched character.
119
+ presentations.length = 0;
120
+ for (const client of touched) {
121
+ try {
122
+ client.fail(error);
123
+ } catch (callbackError) {
124
+ queueMicrotask(() => {
125
+ throw callbackError;
126
+ });
127
+ }
128
+ }
129
+ }
130
+ lastRenderMilliseconds = submittedAt - started + presentationCost;
131
+ lastRenderWaitMilliseconds = Math.max(
132
+ 0,
133
+ performance.now() - started - lastRenderMilliseconds,
134
+ );
135
+ if (presentations.length) {
136
+ const measured = lastRenderMilliseconds / presentations.length;
137
+ // React immediately to a slower GPU; recover capacity gradually to avoid
138
+ // alternating huge batches and long blocked browser frames.
139
+ renderCost = Math.max(measured, renderCost * 0.9 + measured * 0.1, 0.1);
42
140
  }
43
- lastRenderMilliseconds = performance.now() - started;
141
+ } finally {
142
+ rendering = false;
143
+ schedule();
44
144
  }
45
- schedule();
46
145
  }
47
146
  function visibility() {
48
147
  for (const client of instances) client.visibility();
@@ -50,7 +149,7 @@ function visibility() {
50
149
  }
51
150
  const instances = new Set();
52
151
 
53
- /** Original engine and shared animation clock; contour caches are isolated by legacy-engine. */
152
+ /** Original engine and shared animation clock; scoped geometry identities stay distinct. */
54
153
  export async function createAvatar(canvas, initial, callbacks = {}) {
55
154
  if (!initial.config.character && !initial.legacy)
56
155
  throw new Error('A recovered character recipe is required');
@@ -58,9 +157,7 @@ export async function createAvatar(canvas, initial, callbacks = {}) {
58
157
  return createManagedAvatar(canvas, initial, callbacks, createCharacter);
59
158
  }
60
159
  function createCharacter(canvas, initial, callbacks) {
61
- return initial.legacy
62
- ? createLegacyCharacter(canvas, initial, callbacks)
63
- : createLiveCharacter(canvas, initial, callbacks);
160
+ return createScopedCharacter(canvas, initial, callbacks);
64
161
  }
65
162
 
66
163
  async function createLiveCharacter(
@@ -70,34 +167,41 @@ async function createLiveCharacter(
70
167
  isCurrent = () => true,
71
168
  previousImage = null,
72
169
  ) {
170
+ const authoredParts = authoredPartsFor(initial);
73
171
  const lease = initial.legacy?.points
74
- ? await acquireLegacyEngine(initial.legacy.points)
172
+ ? await acquireLegacyEngine(initial.legacy.points, authoredParts)
75
173
  : null;
76
- const module = lease?.module ?? (await engine());
174
+ const module = lease?.module ?? (await getCharacterEngine(authoredParts));
175
+ let releasePreparation = await acquireCharacterPreparation();
176
+ await waitForSharedRender();
77
177
  if (!isCurrent() || !canvas.isConnected) {
178
+ releasePreparation();
78
179
  lease?.release();
79
180
  return { update() {}, dispose() {}, diagnostics: () => ({ ready: false }) };
80
181
  }
81
- const id = `bloom-character-${++serial}`;
82
182
  const output = canvas.getContext('2d');
83
183
  if (!output) {
184
+ releasePreparation();
84
185
  lease?.release();
85
186
  throw new Error('Character output canvas is unavailable');
86
187
  }
87
- const makeSurface = () => {
88
- const node = document.createElement('canvas');
89
- node.id = id;
90
- node.style.display = 'none';
91
- node.setAttribute('aria-hidden', 'true');
92
- canvas.parentElement.appendChild(node);
93
- return node;
94
- };
95
- let surface = makeSurface();
188
+ let surfaceLease;
189
+ try {
190
+ surfaceLease = acquireSharedSurface(canvas.parentElement);
191
+ } catch (error) {
192
+ releasePreparation();
193
+ lease?.release();
194
+ throw error;
195
+ }
196
+ const supportsActivities = !initial.portrait;
96
197
  let character;
97
198
  try {
98
- character = new module.Character(`#${id}`, 128, 128);
199
+ character = surfaceLease.create(module, 128, 128, {
200
+ activities: supportsActivities,
201
+ });
99
202
  } catch (error) {
100
- surface.remove();
203
+ releasePreparation();
204
+ surfaceLease.release();
101
205
  lease?.release();
102
206
  throw error;
103
207
  }
@@ -118,6 +222,9 @@ async function createLiveCharacter(
118
222
  episode = 0n,
119
223
  activity = 0;
120
224
  let workTimer,
225
+ workRequest = 0,
226
+ stoppingWork = false,
227
+ queuedReaction = false,
121
228
  workDuration,
122
229
  lastActivityResult,
123
230
  previousWork,
@@ -125,7 +232,64 @@ async function createLiveCharacter(
125
232
  let pointerId, pointerStart, previousReaction;
126
233
  let lastReaction = null,
127
234
  lastReactionKind = null;
128
- character.setQuality(initial.portrait ? 1 : 0); // Original automatic quality; compact static thumbnails.
235
+ let attentionStarted = 0,
236
+ attentionUntil = 0;
237
+ const attend = (duration) => {
238
+ attentionStarted = performance.now();
239
+ attentionUntil = attentionStarted + duration;
240
+ };
241
+ // hasPendingUpdate also includes reactions whose clock is suspended while
242
+ // Work owns the character. Geometry completion must use preparation revisions,
243
+ // otherwise that old reaction holds the global preparation lease forever.
244
+ const scenePrepared = () => {
245
+ const state = character.preparationStats();
246
+ return (
247
+ !state.pending &&
248
+ !state.failed &&
249
+ state.generation === state.committedGeneration
250
+ );
251
+ };
252
+ let acquiringPreparation = false;
253
+ const preparationQueue = [];
254
+ function finishPreparation(force = false) {
255
+ if (!force && (acquiringPreparation || preparationQueue.length)) return;
256
+ const release = releasePreparation;
257
+ releasePreparation = undefined;
258
+ release?.();
259
+ }
260
+ function prepare(operation) {
261
+ if (disposed) return;
262
+ if (releasePreparation && !sharedRenderPending()) {
263
+ operation();
264
+ return;
265
+ }
266
+ preparationQueue.push(operation);
267
+ if (acquiringPreparation) return;
268
+ acquiringPreparation = true;
269
+ const admission = releasePreparation
270
+ ? Promise.resolve(releasePreparation)
271
+ : acquireCharacterPreparation();
272
+ void admission.then(async (release) => {
273
+ await waitForSharedRender();
274
+ acquiringPreparation = false;
275
+ if (disposed) {
276
+ preparationQueue.length = 0;
277
+ release();
278
+ return;
279
+ }
280
+ releasePreparation = release;
281
+ try {
282
+ for (const next of preparationQueue.splice(0)) next();
283
+ if (scenePrepared()) finishPreparation();
284
+ wake();
285
+ } catch (error) {
286
+ finishPreparation();
287
+ callbacks.onError?.(error);
288
+ }
289
+ });
290
+ }
291
+
292
+ character.setQuality(1); // Resize selects mesh detail using CSS size, independent of DPR.
129
293
  // Readiness means painted pixels, not merely an engine submission.
130
294
  const play = (kind) => {
131
295
  if (activity === kind) return;
@@ -146,91 +310,39 @@ async function createLiveCharacter(
146
310
  // A queued still must finish if its selector moves it offscreen mid-prepare;
147
311
  // otherwise one clipped slot blocks every visible thumbnail until timeout.
148
312
  const active = () => (visible || props.portrait) && !document.hidden;
313
+ let wakeQueued = false;
149
314
  const wake = () => {
150
315
  if (disposed) return;
316
+ if (sharedRenderPending()) {
317
+ if (!wakeQueued) {
318
+ wakeQueued = true;
319
+ void waitForSharedRender().then(() => {
320
+ wakeQueued = false;
321
+ wake();
322
+ });
323
+ }
324
+ return;
325
+ }
151
326
  character.setActive(active());
152
327
  if (active() && (dirty || !ready || animate())) clients.add(client);
153
328
  else clients.delete(client);
154
329
  schedule();
155
330
  };
156
331
  const client = {
332
+ preparing: () => Boolean(releasePreparation),
333
+ attention: (time) => (time < attentionUntil ? attentionStarted : 0),
157
334
  visibility: wake,
335
+ fail(error) {
336
+ clients.delete(client);
337
+ finishPreparation(true);
338
+ callbacks.onError?.(error);
339
+ },
158
340
  render(time) {
159
341
  try {
160
- const submitted = character.render(time);
161
- if (submitted) {
162
- if (
163
- !fit &&
164
- !character.preparationStats().pending &&
165
- !character.hasPendingUpdate()
166
- ) {
167
- measurement.width = canvas.width;
168
- measurement.height = canvas.height;
169
- measure.drawImage(surface, 0, 0, canvas.width, canvas.height);
170
- const { data, width, height } = measure.getImageData(
171
- 0,
172
- 0,
173
- canvas.width,
174
- canvas.height,
175
- );
176
- let minX = width,
177
- minY = height,
178
- maxX = -1,
179
- maxY = -1;
180
- for (let y = 0; y < height; y++)
181
- for (let x = 0; x < width; x++) {
182
- if (data[(y * width + x) * 4 + 3] > 24) {
183
- minX = Math.min(minX, x);
184
- maxX = Math.max(maxX, x);
185
- minY = Math.min(minY, y);
186
- maxY = Math.max(maxY, y);
187
- }
188
- }
189
- if (maxX >= minX) {
190
- const extent = Math.max(maxX - minX + 1, maxY - minY + 1) * 1.22;
191
- fit = [
192
- (minX + maxX) / 2 - extent / 2,
193
- (minY + maxY) / 2 - extent / 2,
194
- extent,
195
- ];
196
- }
197
- }
198
- // Retain the previous portrait while a changed shape is preparing.
199
- if (!fit) return;
200
- output.clearRect(0, 0, canvas.width, canvas.height);
201
- if (fit)
202
- output.drawImage(
203
- surface,
204
- fit[0],
205
- fit[1],
206
- fit[2],
207
- fit[2],
208
- 0,
209
- 0,
210
- canvas.width,
211
- canvas.height,
212
- );
213
- else output.drawImage(surface, 0, 0, canvas.width, canvas.height);
214
- }
215
- if (submitted && fit && transitionImage) {
216
- // Geometry preparation keeps the previous image; blend only after the
217
- // new original-engine surface has pixels. Reduced motion skips it.
218
- transitionStarted ??= time;
219
- const progress = animate()
220
- ? Math.min(1, (time - transitionStarted) / 0.18)
221
- : 1;
222
- if (progress < 1) {
223
- output.globalAlpha = 1 - progress;
224
- output.drawImage(
225
- transitionImage,
226
- 0,
227
- 0,
228
- canvas.width,
229
- canvas.height,
230
- );
231
- output.globalAlpha = 1;
232
- } else transitionImage = null;
233
- }
342
+ const submitted = surfaceLease.render(
343
+ () => character.render(time),
344
+ scenePrepared,
345
+ );
234
346
  const renderError = character.renderError();
235
347
  if (renderError || character.preparationStats().failed)
236
348
  throw new Error(
@@ -238,113 +350,293 @@ async function createLiveCharacter(
238
350
  character.preparationError() ||
239
351
  'Character preparation failed',
240
352
  );
241
- if (
242
- submitted &&
243
- fit &&
244
- !character.preparationStats().pending &&
245
- !character.hasPendingUpdate() &&
246
- fingerprint === pendingFingerprint
247
- ) {
248
- dirty = false;
249
- if (!ready) {
250
- ready = true;
251
- callbacks.onReady?.();
252
- }
253
- callbacks.onPaint?.(!transitionImage);
254
- if (activityMode && workDuration && !workTimer) {
255
- workTimer = setTimeout(() => {
256
- workTimer = undefined;
257
- workDuration = undefined;
258
- play(0);
259
- switchMode(false);
260
- dirty = true;
261
- wake();
262
- }, workDuration);
263
- }
264
- if (!animate()) clients.delete(client);
353
+ // Return to pointer/reaction control only after the authored Work outro
354
+ // clears its phase and episode; render skips do not indicate completion.
355
+ if (stoppingWork && module.controllerState(character).settled) {
356
+ stoppingWork = false;
357
+ switchMode(false);
265
358
  }
359
+ if (queuedReaction && !activityMode) attemptReaction();
360
+ if (!submitted) return;
361
+ const revision = surfaceLease.revision;
362
+ return () => {
363
+ if (disposed || revision !== surfaceLease.revision) return;
364
+ try {
365
+ if (submitted) {
366
+ if (!fit && scenePrepared()) {
367
+ measurement.width = canvas.width;
368
+ measurement.height = canvas.height;
369
+ surfaceLease.copy(
370
+ measure,
371
+ 0,
372
+ 0,
373
+ canvas.width,
374
+ canvas.height,
375
+ 0,
376
+ 0,
377
+ canvas.width,
378
+ canvas.height,
379
+ );
380
+ const { data, width, height } = measure.getImageData(
381
+ 0,
382
+ 0,
383
+ canvas.width,
384
+ canvas.height,
385
+ );
386
+ let minX = width,
387
+ minY = height,
388
+ maxX = -1,
389
+ maxY = -1;
390
+ for (let y = 0; y < height; y++)
391
+ for (let x = 0; x < width; x++) {
392
+ if (data[(y * width + x) * 4 + 3] > 24) {
393
+ minX = Math.min(minX, x);
394
+ maxX = Math.max(maxX, x);
395
+ minY = Math.min(minY, y);
396
+ maxY = Math.max(maxY, y);
397
+ }
398
+ }
399
+ if (maxX >= minX) {
400
+ const extent =
401
+ Math.max(maxX - minX + 1, maxY - minY + 1) * 1.22;
402
+ fit = [
403
+ (minX + maxX) / 2 - extent / 2,
404
+ (minY + maxY) / 2 - extent / 2,
405
+ extent,
406
+ ];
407
+ }
408
+ }
409
+ // Retain the previous portrait while a changed shape is preparing.
410
+ if (!fit) return;
411
+ output.clearRect(0, 0, canvas.width, canvas.height);
412
+ if (fit)
413
+ surfaceLease.copy(
414
+ output,
415
+ fit[0],
416
+ fit[1],
417
+ fit[2],
418
+ fit[2],
419
+ 0,
420
+ 0,
421
+ canvas.width,
422
+ canvas.height,
423
+ );
424
+ else surfaceLease.copy(output);
425
+ }
426
+ if (submitted && fit && transitionImage) {
427
+ // Geometry preparation keeps the previous image; blend only after the
428
+ // new original-engine surface has pixels. Reduced motion skips it.
429
+ transitionStarted ??= time;
430
+ const progress = animate()
431
+ ? Math.min(1, (time - transitionStarted) / 0.18)
432
+ : 1;
433
+ if (progress < 1) {
434
+ output.globalAlpha = 1 - progress;
435
+ output.drawImage(
436
+ transitionImage,
437
+ 0,
438
+ 0,
439
+ canvas.width,
440
+ canvas.height,
441
+ );
442
+ output.globalAlpha = 1;
443
+ } else transitionImage = null;
444
+ }
445
+ if (
446
+ submitted &&
447
+ fit &&
448
+ scenePrepared() &&
449
+ fingerprint === pendingFingerprint
450
+ ) {
451
+ dirty = false;
452
+ finishPreparation();
453
+ if (!ready) {
454
+ ready = true;
455
+ callbacks.onReady?.();
456
+ }
457
+ callbacks.onPaint?.(!transitionImage);
458
+ if (activityMode && workDuration && !workTimer) {
459
+ const request = workRequest;
460
+ workTimer = setTimeout(() => {
461
+ if (request !== workRequest) return;
462
+ workTimer = undefined;
463
+ workDuration = undefined;
464
+ prepare(() => {
465
+ if (request !== workRequest) return;
466
+ play(0);
467
+ stoppingWork = true;
468
+ });
469
+ dirty = true;
470
+ wake();
471
+ }, workDuration);
472
+ }
473
+ if (!animate()) clients.delete(client);
474
+ }
475
+ } catch (error) {
476
+ clients.delete(client);
477
+ finishPreparation();
478
+ callbacks.onError?.(error);
479
+ }
480
+ };
266
481
  } catch (error) {
267
482
  clients.delete(client);
483
+ finishPreparation();
268
484
  callbacks.onError?.(error);
269
485
  }
270
486
  },
271
487
  };
488
+ let currentQuality, currentScale;
272
489
  const resize = () => {
273
- const css = Math.max(1, Math.min(canvas.clientWidth, canvas.clientHeight));
274
- const pixels = Math.max(
275
- 64,
276
- Math.min(512, Math.round(css * Math.min(devicePixelRatio || 1, 2))),
490
+ const cssSize = Math.max(
491
+ 1,
492
+ Math.min(canvas.clientWidth, canvas.clientHeight),
277
493
  );
278
- if (canvas.width !== pixels || canvas.height !== pixels) {
279
- canvas.width = pixels;
280
- canvas.height = pixels;
281
- fit = null;
282
- }
283
- character.resize(pixels, pixels);
284
- character.setDisplayScale(pixels / css);
285
- dirty = true;
286
- wake();
494
+ const backingSize = characterPixels(canvas);
495
+ if (
496
+ canvas.width === backingSize &&
497
+ canvas.height === backingSize &&
498
+ currentQuality === (props.portrait || cssSize <= 128 ? 1 : 2) &&
499
+ currentScale === backingSize / cssSize
500
+ )
501
+ return;
502
+ prepare(() => {
503
+ const css = Math.max(
504
+ 1,
505
+ Math.min(canvas.clientWidth, canvas.clientHeight),
506
+ );
507
+ const pixels = characterPixels(canvas);
508
+ if (canvas.width !== pixels || canvas.height !== pixels) {
509
+ canvas.width = pixels;
510
+ canvas.height = pixels;
511
+ fit = null;
512
+ }
513
+ const quality = props.portrait || css <= 128 ? 1 : 2;
514
+ if (currentQuality !== quality) {
515
+ character.setQuality(quality);
516
+ currentQuality = quality;
517
+ }
518
+ character.resize(pixels, pixels);
519
+ if (currentScale !== pixels / css) {
520
+ character.setDisplayScale(pixels / css);
521
+ currentScale = pixels / css;
522
+ }
523
+ dirty = true;
524
+ wake();
525
+ });
526
+ };
527
+ const bindController = (next = false) => {
528
+ if (!supportsActivities) return;
529
+ const result = character.applyActivity({
530
+ command: 3,
531
+ sequence: 0n,
532
+ episodeId: 0n,
533
+ episodeHighWater: 0n,
534
+ activity: 0,
535
+ outcome: 0,
536
+ entry: 1,
537
+ });
538
+ if (result !== 0)
539
+ throw new Error(`Character activity initialization failed: ${result}`);
540
+ module.controllerMode(character, next);
287
541
  };
288
542
  const switchMode = (next, resetAppearance = false) => {
289
- if (activityMode === next && !resetAppearance) return;
290
- const state = new Uint8Array(character.state());
291
- character.delete();
292
- surface.remove();
293
- surface = makeSurface();
294
- character = new module.Character(`#${id}`, 128, 128, { activities: next });
543
+ if (resetAppearance) {
544
+ // Only a frozen appearance edit needs a new native scene: the original
545
+ // appearance blend otherwise stops on its first frame. React/Work never
546
+ // enter this branch and keep their character, preparation, clock and fit.
547
+ const state = new Uint8Array(character.state());
548
+ character.delete();
549
+ character = surfaceLease.create(module, 128, 128, {
550
+ activities: supportsActivities,
551
+ });
552
+ currentQuality = currentScale = undefined;
553
+ sequence = episode = 0n;
554
+ activity = 0;
555
+ character.setQuality(1);
556
+ character.restore(state);
557
+ character.setReducedMotion(false);
558
+ bindController(next);
559
+ resize();
560
+ } else {
561
+ if (activityMode === next) return;
562
+ surfaceLease.beginTransition(180);
563
+ module.controllerMode(character, next);
564
+ }
295
565
  activityMode = next;
296
- fit = null;
297
- sequence = 0n;
298
- episode = 0n;
299
- activity = 0;
300
- ready = false;
301
566
  dirty = true;
302
- character.setQuality(props.portrait ? 1 : 0);
303
- character.restore(state);
304
- // This recovered engine paints a transparent frame when reduced motion is
305
- // enabled before its first appearance. Freeze our clock after a painted
306
- // frame instead, preserving both initial visibility and reduced motion.
307
- character.setReducedMotion(false);
308
-
309
- if (next)
310
- character.applyActivity({
311
- command: 3,
312
- sequence: 0n,
313
- episodeId: 0n,
314
- episodeHighWater: 0n,
315
- activity: 0,
316
- outcome: 0,
317
- entry: 1,
318
- });
319
- resize();
320
567
  };
321
- const react = () => {
322
- if (!ready || !animate()) return;
323
- if (activityMode) return;
568
+ const attemptReaction = () => {
324
569
  lastReactionKind = 2;
325
- lastReaction = character.playReaction(lastReactionKind);
326
- // Authored signatures belong to named presets. Catalog edits and migrated
327
- // bodies use the same engine's Wave when that signature is unsupported.
328
- if (lastReaction === 2) {
329
- lastReactionKind = 1;
330
- lastReaction = character.playReaction(lastReactionKind);
331
- }
332
- dirty = true;
333
- wake();
570
+ lastReaction = module.controllerSignature(
571
+ character,
572
+ bodySignatureFor(props),
573
+ );
574
+ if (lastReaction === 2)
575
+ throw new Error('Original body signature is unavailable');
576
+ // An interrupted signature resumes its clock on the next reactive render.
577
+ // Keep one latest request queued until that signature releases the controller.
578
+ queuedReaction = lastReaction === 1;
579
+ };
580
+ const react = () => {
581
+ const request = ++workRequest;
582
+ clearTimeout(workTimer);
583
+ workTimer = workDuration = undefined;
584
+ const apply = () => {
585
+ if (disposed || request !== workRequest || !ready || !animate()) return;
586
+ attend(3000);
587
+ queuedReaction = true;
588
+ if (activityMode) {
589
+ play(0);
590
+ stoppingWork = true;
591
+ } else attemptReaction();
592
+ dirty = true;
593
+ wake();
594
+ };
595
+ if (sharedRenderPending()) void waitForSharedRender().then(apply);
596
+ else apply();
334
597
  };
335
598
  const work = () => {
336
- if (!animate()) return;
599
+ // Invalidate an already queued Stop as soon as a new request arrives,
600
+ // including requests made while the previous GPU batch is still in flight.
601
+ const request = ++workRequest;
337
602
  clearTimeout(workTimer);
338
- switchMode(true);
339
- play(1);
340
- workTimer = undefined;
341
- workDuration = 2200 * Math.max(1, Math.min(10, props.workingCycles || 1));
603
+ workTimer = workDuration = undefined;
604
+ prepare(() => {
605
+ if (request !== workRequest || !animate() || !supportsActivities) return;
606
+ queuedReaction = false;
607
+ stoppingWork = false;
608
+ switchMode(true);
609
+ play(1);
610
+ workDuration = 2200 * Math.max(1, Math.min(10, props.workingCycles || 1));
611
+ attend(workDuration + 1000);
612
+ });
613
+ };
614
+ let appearanceTimer, pendingFingerprint, lastCapabilities;
615
+ const reportCapabilities = (available, selected) => {
616
+ lastCapabilities = {
617
+ key: JSON.stringify(
618
+ isLegacyRecipe(props) ? characterRecipe(props) : props.config.character,
619
+ ),
620
+ available,
621
+ selected,
622
+ };
623
+ callbacks.onCapabilities?.(lastCapabilities);
342
624
  };
343
- let appearanceTimer, pendingFingerprint;
344
625
  const applyAppearance = (value) => {
345
626
  const recipe = value;
346
- const nextFingerprint = JSON.stringify(recipe);
627
+ const nextFingerprint = appearanceFingerprint(recipe);
347
628
  if (nextFingerprint !== fingerprint) {
629
+ const defaultPreset = ORIGINAL_PRESETS.includes(recipe.preset)
630
+ ? recipe.preset
631
+ : authoredParts.bodyPreset;
632
+ const sourcePaint = defaultPreset
633
+ ? originalPalettePaint(module, defaultPreset)
634
+ : null;
635
+ const customizedParts = Object.keys(authoredParts).length > 0;
636
+ const implicitBodyColor =
637
+ customizedParts && !recipe.bodyColor && !recipe.selections?.color
638
+ ? sourcePaint?.bodyColor
639
+ : undefined;
348
640
  // Catalog selection owns the engine's part transitions. Restoring the
349
641
  // named preset on every selection needlessly resets the whole character.
350
642
  const removedOverride = Object.keys(appliedRecipe?.selections ?? {}).some(
@@ -356,28 +648,74 @@ async function createLiveCharacter(
356
648
  removedOverride ||
357
649
  (appliedRecipe.bodyColor && !recipe.bodyColor)
358
650
  ) {
359
- const base = props.legacy
360
- ? encodeAppearance({
361
- version: 1,
362
- shape: 'circle',
363
- color: 'blue',
364
- eyes: 'oval',
365
- eyewear: 'none',
366
- accessories: [],
367
- accessoryColors: {},
368
- constrained: 0,
369
- depth: 0.5,
370
- model: null,
371
- rig: null,
372
- hereCharacter: null,
373
- })
374
- : module.presetAppearance(recipe.preset);
651
+ let base =
652
+ props.legacy ||
653
+ recipe.preset === 'clippo' ||
654
+ recipe.preset === 'legacy' ||
655
+ authoredParts.bodyPreset ||
656
+ (sourcePaint?.bodyColor && customizedParts)
657
+ ? encodeAppearance({
658
+ version: 1,
659
+ shape: 'circle',
660
+ color: sourcePaint?.color ?? 'blue',
661
+ eyes: 'dots',
662
+ eyewear: 'none',
663
+ accessories: [],
664
+ accessoryColors: {},
665
+ constrained: 0,
666
+ depth: 0.5,
667
+ model: null,
668
+ rig: null,
669
+ hereCharacter: null,
670
+ })
671
+ : module.presetAppearance(recipe.preset);
672
+ if (
673
+ !props.legacy &&
674
+ recipe.preset !== 'clippo' &&
675
+ recipe.preset !== 'legacy' &&
676
+ !authoredParts.bodyPreset &&
677
+ Object.keys(authoredParts).length
678
+ ) {
679
+ // Selecting the preset's existing shape/backing eye is a native no-op,
680
+ // so select() never clears HERE's material overrides. Donor pieces
681
+ // need an editable controller while retaining all authored defaults.
682
+ const editable = decodeAppearance(base);
683
+ editable.hereCharacter = null;
684
+ base = encodeAppearance(editable);
685
+ }
375
686
  if (character.restore(base))
376
687
  throw new Error('Invalid character appearance');
377
688
  }
378
689
  for (const key of Object.keys(category)) {
379
- const value = recipe.selections?.[key];
380
- if (!value) continue;
690
+ const selected =
691
+ (key === 'shape' ? authoredParts.faceShape : undefined) ??
692
+ authoredParts[key] ??
693
+ recipe.selections?.[key];
694
+ // Virtual choices replace copied prepared meshes in the worker. The
695
+ // original engine still selects its own valid backing geometry.
696
+ const value =
697
+ key === 'shape' && MIGRATED_SHAPE_IDS.includes(selected)
698
+ ? 'circle'
699
+ : key === 'shape' && selected === 'todd'
700
+ ? 'circle'
701
+ : key === 'shape' && selected === 'clippo'
702
+ ? 'circle'
703
+ : key === 'eyes' && authoredParts.eyes
704
+ ? ['oval', 'round_inset', 'dots', ...NATIVE_PARTS.eyes].find(
705
+ (id) => character.isAvailable(category.eyes, id),
706
+ )
707
+ : key === 'accessory' && authoredParts.accessory
708
+ ? 'none'
709
+ : key === 'eyewear' && authoredParts.eyewear
710
+ ? 'none'
711
+ : selected;
712
+ if (!value) {
713
+ if (key === 'eyes' && authoredParts.eyes)
714
+ throw new Error(
715
+ `No compatible fitting reference for ${selected} eyes`,
716
+ );
717
+ continue;
718
+ }
381
719
  if (key === 'accessory' && value === 'none') {
382
720
  for (const item of module.catalog(4))
383
721
  if (character.isSelected(4, item.id)) character.select(4, item.id);
@@ -390,8 +728,10 @@ async function createLiveCharacter(
390
728
  throw new Error(`Unsupported character selection: ${key}`);
391
729
  }
392
730
  const patch =
393
- props.legacy?.patch ??
394
- (recipe.bodyColor ? { bodyColor: recipe.bodyColor } : null);
731
+ (isLegacyRecipe(props) ? props.legacy?.patch : undefined) ??
732
+ (recipe.bodyColor || implicitBodyColor
733
+ ? { bodyColor: recipe.bodyColor ?? implicitBodyColor }
734
+ : null);
395
735
  if (patch) {
396
736
  // One authored preset uses an unnamed color. Enter the engine's normal
397
737
  // editable palette before replacing that paint with an explicit RGB.
@@ -429,34 +769,107 @@ async function createLiveCharacter(
429
769
  const selected = {};
430
770
  for (const [key, index] of Object.entries(category)) {
431
771
  for (const item of module.catalog(index)) {
432
- available[`${key}:${item.id}`] = character.isAvailable(
433
- index,
434
- item.id,
435
- );
772
+ available[`${key}:${item.id}`] = NATIVE_PARTS[key]?.includes(item.id)
773
+ ? true
774
+ : character.isAvailable(index, item.id);
436
775
  if (character.isSelected(index, item.id)) selected[key] = item.id;
437
776
  }
438
777
  }
778
+ if (authoredParts.bodyPreset)
779
+ Object.assign(
780
+ selected,
781
+ bodySelectionDefaults(
782
+ authoredParts.bodyPreset,
783
+ decodeAppearance(module.presetAppearance(authoredParts.bodyPreset)),
784
+ ),
785
+ );
786
+ for (const key of ['eyes', 'eyewear', 'accessory'])
787
+ if (authoredParts[key]) selected[key] = authoredParts[key];
788
+ available['shape:todd'] = true;
789
+ for (const id of MIGRATED_SHAPE_IDS) available[`shape:${id}`] = true;
790
+ if (MIGRATED_SHAPE_IDS.includes(recipe.selections?.shape))
791
+ selected.shape = recipe.selections.shape;
792
+ available['eyes:cyclops'] = true;
793
+ if (
794
+ recipe.selections?.shape === 'todd' ||
795
+ authoredParts.bodyPreset === 'lime_frog' ||
796
+ (recipe.preset === 'lime_frog' && !recipe.selections?.shape)
797
+ )
798
+ selected.shape = 'todd';
799
+ available['shape:clippo'] = true;
800
+ available['eyes:clippo'] = true;
801
+ available['eyes:todd'] = true;
802
+ if (authoredParts.shape) selected.shape = authoredParts.shape;
803
+ available['accessory:felipe_beret'] = true;
804
+ if (
805
+ authoredParts.eyes ||
806
+ (recipe.preset === 'lime_frog' && !recipe.selections?.eyes)
807
+ )
808
+ selected.eyes = authoredParts.eyes ?? 'todd';
809
+ if (
810
+ authoredParts.accessory ||
811
+ (recipe.preset === 'blue_beret' && !recipe.selections?.accessory)
812
+ )
813
+ selected.accessory = authoredParts.accessory ?? 'felipe_beret';
439
814
  available['accessory:none'] = true;
440
815
  selected.accessory ??= 'none';
441
- callbacks.onCapabilities?.({ key: fingerprint, available, selected });
816
+ const originalPreset = ORIGINAL_PRESETS.includes(recipe.preset)
817
+ ? recipe.preset
818
+ : authoredParts.bodyPreset;
819
+ if (
820
+ !recipe.bodyColor &&
821
+ !recipe.selections?.color &&
822
+ ORIGINAL_PRESETS.includes(originalPreset) &&
823
+ !isLegacyRecipe(props)
824
+ ) {
825
+ const originalColor = decodeAppearance(
826
+ module.presetAppearance(originalPreset),
827
+ ).color;
828
+ if (
829
+ !module
830
+ .catalog(category.color)
831
+ .some((item) => item.id === originalColor)
832
+ ) {
833
+ delete selected.color;
834
+ selected.bodyColor = originalBodyColor(module, originalPreset);
835
+ }
836
+ }
837
+ if (recipe.bodyColor) {
838
+ delete selected.color;
839
+ selected.bodyColor = recipe.bodyColor;
840
+ }
841
+ reportCapabilities(available, selected);
442
842
  dirty = true;
443
843
  }
444
844
  };
845
+ let deferredUpdate;
445
846
  const update = (next, silent = false) => {
446
847
  if (disposed) return;
848
+ if (sharedRenderPending()) {
849
+ const queued = Boolean(deferredUpdate);
850
+ deferredUpdate = [next, silent];
851
+ if (!queued)
852
+ void waitForSharedRender().then(() => {
853
+ const value = deferredUpdate;
854
+ deferredUpdate = undefined;
855
+ if (value && !disposed) update(...value);
856
+ });
857
+ return;
858
+ }
447
859
  props = next;
448
- const recipe = props.legacy
449
- ? {
450
- preset: 'legacy',
451
- selections: {
452
- shape: props.legacy.shape ?? 'circle',
453
- eyes: props.legacy.eyes ?? 'oval',
454
- ...props.legacy.selections,
455
- },
456
- ...props.legacy.patch,
457
- }
458
- : props.config.character;
459
- const nextFingerprint = JSON.stringify(recipe);
860
+ const recipe = characterRecipe(props);
861
+ const nextFingerprint = appearanceFingerprint(recipe);
862
+ // Explicitly choosing a preset's default changes its saved recipe, but not
863
+ // its prepared appearance. Re-key the actual capabilities without rebuilding.
864
+ const rawKey = JSON.stringify(
865
+ isLegacyRecipe(props) ? recipe : props.config.character,
866
+ );
867
+ if (
868
+ nextFingerprint === fingerprint &&
869
+ lastCapabilities &&
870
+ lastCapabilities.key !== rawKey
871
+ )
872
+ reportCapabilities(lastCapabilities.available, lastCapabilities.selected);
460
873
  // Dragging the shared color picker may emit dozens of values per second.
461
874
  // Prepare only its latest value while retaining the existing painted frame.
462
875
  if (pendingFingerprint !== nextFingerprint) {
@@ -470,13 +883,19 @@ async function createLiveCharacter(
470
883
  appearanceTimer = setTimeout(() => {
471
884
  if (disposed) return;
472
885
  try {
473
- applyAppearance(recipe);
886
+ prepare(() => {
887
+ if (pendingFingerprint === nextFingerprint)
888
+ applyAppearance(recipe);
889
+ });
474
890
  wake();
475
891
  } catch (error) {
476
892
  callbacks.onError?.(error);
477
893
  }
478
894
  }, 80);
479
- } else applyAppearance(recipe);
895
+ } else
896
+ prepare(() => {
897
+ if (pendingFingerprint === nextFingerprint) applyAppearance(recipe);
898
+ });
480
899
  }
481
900
  // The recovered engine needs its first appearance rendered with this off.
482
901
  // Our scheduler freezes the completed frame instead.
@@ -520,6 +939,18 @@ async function createLiveCharacter(
520
939
  dirty = true;
521
940
  wake();
522
941
  };
942
+ function input(operation) {
943
+ const apply = () => {
944
+ if (disposed) return;
945
+ try {
946
+ operation();
947
+ } catch (error) {
948
+ client.fail(error);
949
+ }
950
+ };
951
+ if (sharedRenderPending()) void waitForSharedRender().then(apply);
952
+ else apply();
953
+ }
523
954
  function pointer(event) {
524
955
  if (!props.interactive || !animate()) return;
525
956
  const phase = {
@@ -530,8 +961,11 @@ async function createLiveCharacter(
530
961
  }[event.type];
531
962
  if (event.type === 'pointerdown') {
532
963
  if (event.button !== 0 || pointerId != null || !event.isPrimary) return;
964
+ attend(3000);
533
965
  pointerStart = [event.clientX, event.clientY];
534
966
  pointerId = event.pointerId;
967
+ // Capture while the physical pointer is still down. GPU completion may
968
+ // arrive after pointerup, when setPointerCapture would throw instead.
535
969
  canvas.setPointerCapture(pointerId);
536
970
  }
537
971
  if (pointerId != null && pointerId !== event.pointerId) return;
@@ -541,38 +975,41 @@ async function createLiveCharacter(
541
975
  localY = (event.clientY - r.top) / r.height;
542
976
  const x = fit ? (fit[0] + localX * fit[2]) / canvas.width : localX;
543
977
  const y = fit ? (fit[1] + localY * fit[2]) / canvas.height : localY;
544
- if (activityMode && phase === 1)
545
- character.setReadyGaze(x, y, performance.now() / 1000);
546
- if (!activityMode)
547
- character.pointer(phase, event.pointerId, x, y, performance.now() / 1000);
978
+ const id = event.pointerId,
979
+ at = performance.now() / 1000;
980
+ const tap =
981
+ phase === 2 &&
982
+ pointerStart &&
983
+ Math.hypot(
984
+ event.clientX - pointerStart[0],
985
+ event.clientY - pointerStart[1],
986
+ ) < 6;
987
+ input(() => {
988
+ if (activityMode && phase === 1) character.setReadyGaze(x, y, at);
989
+ if (!activityMode) character.pointer(phase, id, x, y, at);
990
+ if (tap) react();
991
+ dirty = true;
992
+ wake();
993
+ });
548
994
  if (phase === 2 || phase === 3) {
549
- if (
550
- phase === 2 &&
551
- pointerStart &&
552
- Math.hypot(
553
- event.clientX - pointerStart[0],
554
- event.clientY - pointerStart[1],
555
- ) < 6
556
- )
557
- react();
558
- if (canvas.hasPointerCapture(event.pointerId))
559
- canvas.releasePointerCapture(event.pointerId);
995
+ if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
560
996
  pointerId = undefined;
997
+ pointerStart = undefined;
561
998
  }
562
- dirty = true;
563
- wake();
564
999
  }
565
1000
  const cancel = () => {
566
- if (pointerId != null)
567
- character.pointer(3, pointerId, 0.5, 0.5, performance.now() / 1000);
1001
+ const id = pointerId;
568
1002
  pointerId = undefined;
569
1003
  pointerStart = undefined;
1004
+ if (id != null)
1005
+ input(() => character.pointer(3, id, 0.5, 0.5, performance.now() / 1000));
570
1006
  };
571
1007
  const leave = () => {
572
- if (pointerId == null) {
573
- if (activityMode) character.clearReadyGaze(performance.now() / 1000);
574
- else character.pointer(1, 0, 0.5, 0.5, performance.now() / 1000);
575
- }
1008
+ if (pointerId == null)
1009
+ input(() => {
1010
+ if (activityMode) character.clearReadyGaze(performance.now() / 1000);
1011
+ else character.pointer(1, 0, 0.5, 0.5, performance.now() / 1000);
1012
+ });
576
1013
  };
577
1014
  canvas.addEventListener('lostpointercapture', cancel);
578
1015
  canvas.addEventListener('pointerleave', leave);
@@ -602,9 +1039,14 @@ async function createLiveCharacter(
602
1039
  canvas.removeEventListener('lostpointercapture', cancel);
603
1040
  canvas.removeEventListener('pointerleave', leave);
604
1041
  window.removeEventListener('blur', cancel);
605
- character.delete();
606
- surface.remove();
607
- lease?.release();
1042
+ const release = () => {
1043
+ character.delete();
1044
+ finishPreparation(true);
1045
+ surfaceLease.release();
1046
+ lease?.release();
1047
+ };
1048
+ if (sharedRenderPending()) void waitForSharedRender().then(release);
1049
+ else release();
608
1050
  if (!instances.size) {
609
1051
  document.removeEventListener('visibilitychange', visibility);
610
1052
  cancelAnimationFrame(frame);
@@ -614,6 +1056,7 @@ async function createLiveCharacter(
614
1056
  try {
615
1057
  update(initial);
616
1058
  resize();
1059
+ bindController();
617
1060
  } catch (error) {
618
1061
  dispose();
619
1062
  throw error;
@@ -628,18 +1071,21 @@ async function createLiveCharacter(
628
1071
  lastReactionKind,
629
1072
  lastActivityResult,
630
1073
  activityMode,
1074
+ stoppingWork,
1075
+ queuedReaction,
631
1076
  ready,
632
1077
  pending:
633
1078
  dirty ||
634
- Boolean(character.preparationStats().pending) ||
635
- character.hasPendingUpdate(),
1079
+ acquiringPreparation ||
1080
+ preparationQueue.length > 0 ||
1081
+ !scenePrepared(),
636
1082
  }),
637
1083
  };
638
1084
  }
639
1085
 
640
1086
  // A geometry cache must never serve a different silhouette under the same
641
- // appearance key. Keep the old painted image while switching contour modules.
642
- async function createLegacyCharacter(canvas, initial, callbacks) {
1087
+ // appearance key. Keep the old painted image while replacing the scoped character.
1088
+ async function createScopedCharacter(canvas, initial, callbacks) {
643
1089
  let controller,
644
1090
  shapeKey,
645
1091
  revision = 0,
@@ -647,8 +1093,12 @@ async function createLegacyCharacter(canvas, initial, callbacks) {
647
1093
  latest = initial;
648
1094
  const update = async (next, silent = false) => {
649
1095
  if (disposed) return;
1096
+ next = withCharacterGeometry(next);
650
1097
  latest = next;
651
- const key = JSON.stringify(next.legacy.points ?? null);
1098
+ const key = JSON.stringify([
1099
+ next.legacy?.points ?? null,
1100
+ authoredPartsFor(next),
1101
+ ]);
652
1102
  if (shapeKey === key) {
653
1103
  controller?.update(next, silent);
654
1104
  return;
@@ -708,7 +1158,8 @@ async function createLegacyCharacter(canvas, initial, callbacks) {
708
1158
  },
709
1159
  react: () => controller?.react(),
710
1160
  work: () => controller?.work(),
711
- diagnostics: () => controller?.diagnostics(),
1161
+ diagnostics: () =>
1162
+ controller?.diagnostics() ?? { ready: false, pending: !disposed },
712
1163
  };
713
1164
  }
714
1165
 
@@ -723,16 +1174,7 @@ function createPortrait(canvas, initial, callbacks) {
723
1174
  cancel;
724
1175
  const update = (props) => {
725
1176
  if (disposed) return;
726
- const pixels = Math.max(
727
- 64,
728
- Math.min(
729
- 512,
730
- Math.round(
731
- Math.min(canvas.clientWidth, canvas.clientHeight) *
732
- Math.min(devicePixelRatio || 1, 2),
733
- ),
734
- ),
735
- );
1177
+ const pixels = characterPixels(canvas);
736
1178
  const key = JSON.stringify([
737
1179
  props.config.character,
738
1180
  props.legacy,
@@ -778,6 +1220,18 @@ function createPortrait(canvas, initial, callbacks) {
778
1220
  canvas,
779
1221
  { ...props, paused: true, interactive: false },
780
1222
  {
1223
+ onPreparationStart() {
1224
+ if (finished || !current()) return;
1225
+ // Waiting for another avatar's preparation is not engine work.
1226
+ // Start the deadline only after this controller is constructed.
1227
+ timer = setTimeout(() => {
1228
+ if (current())
1229
+ callbacks.onError?.(
1230
+ new Error('Character preview preparation timed out'),
1231
+ );
1232
+ finish();
1233
+ }, 20000);
1234
+ },
781
1235
  onCapabilities(value) {
782
1236
  capabilities = value;
783
1237
  if (current()) callbacks.onCapabilities?.(value);
@@ -805,14 +1259,7 @@ function createPortrait(canvas, initial, callbacks) {
805
1259
  if (finished || !current()) {
806
1260
  controller.dispose();
807
1261
  finish();
808
- } else
809
- timer = setTimeout(() => {
810
- if (current())
811
- callbacks.onError?.(
812
- new Error('Character preview preparation timed out'),
813
- );
814
- finish();
815
- }, 20000);
1262
+ }
816
1263
  await completion;
817
1264
  } catch (error) {
818
1265
  if (current()) callbacks.onError?.(error);
@@ -841,10 +1288,12 @@ const registryKey = Symbol.for('bloom.character.runtimes');
841
1288
  export function runtimeStats() {
842
1289
  return {
843
1290
  renderMilliseconds: lastRenderMilliseconds,
1291
+ renderWaitMilliseconds: lastRenderWaitMilliseconds,
844
1292
  budget: managedAvatarStats(),
1293
+ surface: sharedSurfaceStats(),
845
1294
  instances: instances.size,
846
1295
  active: clients.size,
847
- scheduled: !!frame,
1296
+ scheduled: !!frame || rendering,
848
1297
  cachedPortraits: portraits.size,
849
1298
  legacy: legacyEngineStats(),
850
1299
  };