@oxy.so/bloom 7.1.4 → 7.1.5

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 (220) hide show
  1. package/assets/character-runtime/README.md +19 -114
  2. package/assets/character-runtime/legacy-engine.mjs +54 -406
  3. package/assets/character-runtime/legacy-geometry.mjs +85 -242
  4. package/assets/character-runtime/legacy-worker.mjs +23 -82
  5. package/assets/character-runtime/managed-avatar.mjs +18 -12
  6. package/assets/character-runtime/render-budget.mjs +11 -6
  7. package/assets/character-runtime/runtime.mjs +295 -744
  8. package/docs/agent-avatar.mdx +8 -45
  9. package/docs/agent-creator.mdx +3 -13
  10. package/lib/commonjs/agent-avatar/AgentAvatar.js +1 -1
  11. package/lib/commonjs/agent-avatar/AgentAvatar.js.map +1 -1
  12. package/lib/commonjs/agent-avatar/CharacterAvatar.js +1 -2
  13. package/lib/commonjs/agent-avatar/CharacterAvatar.js.map +1 -1
  14. package/lib/commonjs/agent-avatar/config-character.js +0 -4
  15. package/lib/commonjs/agent-avatar/config-character.js.map +1 -1
  16. package/lib/commonjs/agent-avatar/legacy-recipe.js +6 -19
  17. package/lib/commonjs/agent-avatar/legacy-recipe.js.map +1 -1
  18. package/lib/commonjs/agent-creator/AgentCreatorBase.js +107 -67
  19. package/lib/commonjs/agent-creator/AgentCreatorBase.js.map +1 -1
  20. package/lib/commonjs/agent-creator/CharacterControls.js +5 -23
  21. package/lib/commonjs/agent-creator/CharacterControls.js.map +1 -1
  22. package/lib/commonjs/agent-creator/EmotionPicker.js +1 -8
  23. package/lib/commonjs/agent-creator/EmotionPicker.js.map +1 -1
  24. package/lib/commonjs/agent-creator/ShapeSilhouette.js +1 -8
  25. package/lib/commonjs/agent-creator/ShapeSilhouette.js.map +1 -1
  26. package/lib/commonjs/agent-creator/constants.js +17 -16
  27. package/lib/commonjs/agent-creator/constants.js.map +1 -1
  28. package/lib/commonjs/agent-creator/messages.js +1 -1
  29. package/lib/commonjs/agent-creator/messages.js.map +1 -1
  30. package/lib/commonjs/locale/translations/ar.js +1 -1
  31. package/lib/commonjs/locale/translations/ar.js.map +1 -1
  32. package/lib/commonjs/locale/translations/bn.js +1 -1
  33. package/lib/commonjs/locale/translations/bn.js.map +1 -1
  34. package/lib/commonjs/locale/translations/ca.js +1 -1
  35. package/lib/commonjs/locale/translations/ca.js.map +1 -1
  36. package/lib/commonjs/locale/translations/de.js +1 -1
  37. package/lib/commonjs/locale/translations/de.js.map +1 -1
  38. package/lib/commonjs/locale/translations/es.js +1 -1
  39. package/lib/commonjs/locale/translations/es.js.map +1 -1
  40. package/lib/commonjs/locale/translations/fr.js +1 -1
  41. package/lib/commonjs/locale/translations/fr.js.map +1 -1
  42. package/lib/commonjs/locale/translations/hi.js +1 -1
  43. package/lib/commonjs/locale/translations/hi.js.map +1 -1
  44. package/lib/commonjs/locale/translations/id.js +1 -1
  45. package/lib/commonjs/locale/translations/id.js.map +1 -1
  46. package/lib/commonjs/locale/translations/it.js +1 -1
  47. package/lib/commonjs/locale/translations/it.js.map +1 -1
  48. package/lib/commonjs/locale/translations/ja.js +1 -1
  49. package/lib/commonjs/locale/translations/ja.js.map +1 -1
  50. package/lib/commonjs/locale/translations/pt.js +1 -1
  51. package/lib/commonjs/locale/translations/pt.js.map +1 -1
  52. package/lib/commonjs/locale/translations/ru.js +1 -1
  53. package/lib/commonjs/locale/translations/ru.js.map +1 -1
  54. package/lib/commonjs/locale/translations/tr.js +1 -1
  55. package/lib/commonjs/locale/translations/tr.js.map +1 -1
  56. package/lib/commonjs/locale/translations/zh.js +1 -1
  57. package/lib/commonjs/locale/translations/zh.js.map +1 -1
  58. package/lib/module/agent-avatar/AgentAvatar.js +1 -1
  59. package/lib/module/agent-avatar/AgentAvatar.js.map +1 -1
  60. package/lib/module/agent-avatar/CharacterAvatar.js +1 -2
  61. package/lib/module/agent-avatar/CharacterAvatar.js.map +1 -1
  62. package/lib/module/agent-avatar/config-character.js +0 -4
  63. package/lib/module/agent-avatar/config-character.js.map +1 -1
  64. package/lib/module/agent-avatar/legacy-recipe.js +6 -19
  65. package/lib/module/agent-avatar/legacy-recipe.js.map +1 -1
  66. package/lib/module/agent-creator/AgentCreatorBase.js +109 -69
  67. package/lib/module/agent-creator/AgentCreatorBase.js.map +1 -1
  68. package/lib/module/agent-creator/CharacterControls.js +6 -24
  69. package/lib/module/agent-creator/CharacterControls.js.map +1 -1
  70. package/lib/module/agent-creator/EmotionPicker.js +1 -8
  71. package/lib/module/agent-creator/EmotionPicker.js.map +1 -1
  72. package/lib/module/agent-creator/ShapeSilhouette.js +1 -8
  73. package/lib/module/agent-creator/ShapeSilhouette.js.map +1 -1
  74. package/lib/module/agent-creator/constants.js +16 -8
  75. package/lib/module/agent-creator/constants.js.map +1 -1
  76. package/lib/module/agent-creator/messages.js +1 -1
  77. package/lib/module/agent-creator/messages.js.map +1 -1
  78. package/lib/module/locale/translations/ar.js +1 -1
  79. package/lib/module/locale/translations/ar.js.map +1 -1
  80. package/lib/module/locale/translations/bn.js +1 -1
  81. package/lib/module/locale/translations/bn.js.map +1 -1
  82. package/lib/module/locale/translations/ca.js +1 -1
  83. package/lib/module/locale/translations/ca.js.map +1 -1
  84. package/lib/module/locale/translations/de.js +1 -1
  85. package/lib/module/locale/translations/de.js.map +1 -1
  86. package/lib/module/locale/translations/es.js +1 -1
  87. package/lib/module/locale/translations/es.js.map +1 -1
  88. package/lib/module/locale/translations/fr.js +1 -1
  89. package/lib/module/locale/translations/fr.js.map +1 -1
  90. package/lib/module/locale/translations/hi.js +1 -1
  91. package/lib/module/locale/translations/hi.js.map +1 -1
  92. package/lib/module/locale/translations/id.js +1 -1
  93. package/lib/module/locale/translations/id.js.map +1 -1
  94. package/lib/module/locale/translations/it.js +1 -1
  95. package/lib/module/locale/translations/it.js.map +1 -1
  96. package/lib/module/locale/translations/ja.js +1 -1
  97. package/lib/module/locale/translations/ja.js.map +1 -1
  98. package/lib/module/locale/translations/pt.js +1 -1
  99. package/lib/module/locale/translations/pt.js.map +1 -1
  100. package/lib/module/locale/translations/ru.js +1 -1
  101. package/lib/module/locale/translations/ru.js.map +1 -1
  102. package/lib/module/locale/translations/tr.js +1 -1
  103. package/lib/module/locale/translations/tr.js.map +1 -1
  104. package/lib/module/locale/translations/zh.js +1 -1
  105. package/lib/module/locale/translations/zh.js.map +1 -1
  106. package/lib/typescript/commonjs/agent-avatar/CharacterAvatar.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/agent-avatar/config-character.d.ts +0 -4
  108. package/lib/typescript/commonjs/agent-avatar/config-character.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/agent-avatar/legacy-recipe.d.ts +10 -2
  110. package/lib/typescript/commonjs/agent-avatar/legacy-recipe.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/agent-creator/AgentCreatorBase.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/agent-creator/CharacterControls.d.ts +1 -2
  113. package/lib/typescript/commonjs/agent-creator/CharacterControls.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/agent-creator/EmotionPicker.d.ts +0 -1
  115. package/lib/typescript/commonjs/agent-creator/EmotionPicker.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/agent-creator/ShapeSilhouette.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/agent-creator/constants.d.ts +15 -7
  118. package/lib/typescript/commonjs/agent-creator/constants.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/agent-creator/messages.d.ts.map +1 -1
  120. package/lib/typescript/module/agent-avatar/CharacterAvatar.d.ts.map +1 -1
  121. package/lib/typescript/module/agent-avatar/config-character.d.ts +0 -4
  122. package/lib/typescript/module/agent-avatar/config-character.d.ts.map +1 -1
  123. package/lib/typescript/module/agent-avatar/legacy-recipe.d.ts +10 -2
  124. package/lib/typescript/module/agent-avatar/legacy-recipe.d.ts.map +1 -1
  125. package/lib/typescript/module/agent-creator/AgentCreatorBase.d.ts.map +1 -1
  126. package/lib/typescript/module/agent-creator/CharacterControls.d.ts +1 -2
  127. package/lib/typescript/module/agent-creator/CharacterControls.d.ts.map +1 -1
  128. package/lib/typescript/module/agent-creator/EmotionPicker.d.ts +0 -1
  129. package/lib/typescript/module/agent-creator/EmotionPicker.d.ts.map +1 -1
  130. package/lib/typescript/module/agent-creator/ShapeSilhouette.d.ts.map +1 -1
  131. package/lib/typescript/module/agent-creator/constants.d.ts +15 -7
  132. package/lib/typescript/module/agent-creator/constants.d.ts.map +1 -1
  133. package/lib/typescript/module/agent-creator/messages.d.ts.map +1 -1
  134. package/package.json +1 -1
  135. package/src/agent-avatar/AgentAvatar.tsx +1 -1
  136. package/src/agent-avatar/CharacterAvatar.tsx +1 -4
  137. package/src/agent-avatar/config-character.ts +6 -39
  138. package/src/agent-avatar/legacy-recipe.ts +6 -34
  139. package/src/agent-creator/AgentCreatorBase.tsx +152 -121
  140. package/src/agent-creator/CharacterControls.tsx +14 -39
  141. package/src/agent-creator/EmotionPicker.tsx +16 -55
  142. package/src/agent-creator/ShapeSilhouette.tsx +1 -16
  143. package/src/agent-creator/constants.ts +14 -47
  144. package/src/agent-creator/messages.ts +1 -2
  145. package/src/locale/translations/ar.ts +1 -1
  146. package/src/locale/translations/bn.ts +1 -1
  147. package/src/locale/translations/ca.ts +1 -1
  148. package/src/locale/translations/de.ts +1 -1
  149. package/src/locale/translations/es.ts +1 -1
  150. package/src/locale/translations/fr.ts +1 -1
  151. package/src/locale/translations/hi.ts +1 -1
  152. package/src/locale/translations/id.ts +1 -1
  153. package/src/locale/translations/it.ts +1 -1
  154. package/src/locale/translations/ja.ts +1 -1
  155. package/src/locale/translations/pt.ts +1 -1
  156. package/src/locale/translations/ru.ts +1 -1
  157. package/src/locale/translations/tr.ts +1 -1
  158. package/src/locale/translations/zh.ts +1 -1
  159. package/assets/character-runtime/authored-parts.mjs +0 -754
  160. package/assets/character-runtime/catalog-parts.mjs +0 -362
  161. package/assets/character-runtime/character-recipe.mjs +0 -247
  162. package/assets/character-runtime/clippo-geometry.mjs +0 -679
  163. package/assets/character-runtime/controller-mode.mjs +0 -208
  164. package/assets/character-runtime/cyclops-geometry.mjs +0 -180
  165. package/assets/character-runtime/migrated-contours.mjs +0 -2067
  166. package/assets/character-runtime/migrated-faces.mjs +0 -59
  167. package/assets/character-runtime/pose-transition.mjs +0 -523
  168. package/assets/character-runtime/preparation-cache.mjs +0 -60
  169. package/assets/character-runtime/render-errors.mjs +0 -180
  170. package/assets/character-runtime/resolution.mjs +0 -10
  171. package/assets/character-runtime/sha256.mjs +0 -44
  172. package/assets/character-runtime/shared-programs.mjs +0 -534
  173. package/assets/character-runtime/shared-surface.mjs +0 -490
  174. package/assets/character-runtime/shared-textures.mjs +0 -420
  175. package/assets/character-runtime/thumbnails/eyes/todd.png +0 -0
  176. package/lib/commonjs/agent-avatar/character-colors.js +0 -19
  177. package/lib/commonjs/agent-avatar/character-colors.js.map +0 -1
  178. package/lib/commonjs/agent-avatar/character-shapes.js +0 -106
  179. package/lib/commonjs/agent-avatar/character-shapes.js.map +0 -1
  180. package/lib/commonjs/agent-creator/ClippoArtwork.js +0 -63
  181. package/lib/commonjs/agent-creator/ClippoArtwork.js.map +0 -1
  182. package/lib/commonjs/agent-creator/CyclopsArtwork.js +0 -45
  183. package/lib/commonjs/agent-creator/CyclopsArtwork.js.map +0 -1
  184. package/lib/commonjs/agent-creator/ToddArtwork.js +0 -29
  185. package/lib/commonjs/agent-creator/ToddArtwork.js.map +0 -1
  186. package/lib/module/agent-avatar/character-colors.js +0 -15
  187. package/lib/module/agent-avatar/character-colors.js.map +0 -1
  188. package/lib/module/agent-avatar/character-shapes.js +0 -97
  189. package/lib/module/agent-avatar/character-shapes.js.map +0 -1
  190. package/lib/module/agent-creator/ClippoArtwork.js +0 -59
  191. package/lib/module/agent-creator/ClippoArtwork.js.map +0 -1
  192. package/lib/module/agent-creator/CyclopsArtwork.js +0 -41
  193. package/lib/module/agent-creator/CyclopsArtwork.js.map +0 -1
  194. package/lib/module/agent-creator/ToddArtwork.js +0 -25
  195. package/lib/module/agent-creator/ToddArtwork.js.map +0 -1
  196. package/lib/typescript/commonjs/agent-avatar/character-colors.d.ts +0 -13
  197. package/lib/typescript/commonjs/agent-avatar/character-colors.d.ts.map +0 -1
  198. package/lib/typescript/commonjs/agent-avatar/character-shapes.d.ts +0 -19
  199. package/lib/typescript/commonjs/agent-avatar/character-shapes.d.ts.map +0 -1
  200. package/lib/typescript/commonjs/agent-creator/ClippoArtwork.d.ts +0 -6
  201. package/lib/typescript/commonjs/agent-creator/ClippoArtwork.d.ts.map +0 -1
  202. package/lib/typescript/commonjs/agent-creator/CyclopsArtwork.d.ts +0 -5
  203. package/lib/typescript/commonjs/agent-creator/CyclopsArtwork.d.ts.map +0 -1
  204. package/lib/typescript/commonjs/agent-creator/ToddArtwork.d.ts +0 -6
  205. package/lib/typescript/commonjs/agent-creator/ToddArtwork.d.ts.map +0 -1
  206. package/lib/typescript/module/agent-avatar/character-colors.d.ts +0 -13
  207. package/lib/typescript/module/agent-avatar/character-colors.d.ts.map +0 -1
  208. package/lib/typescript/module/agent-avatar/character-shapes.d.ts +0 -19
  209. package/lib/typescript/module/agent-avatar/character-shapes.d.ts.map +0 -1
  210. package/lib/typescript/module/agent-creator/ClippoArtwork.d.ts +0 -6
  211. package/lib/typescript/module/agent-creator/ClippoArtwork.d.ts.map +0 -1
  212. package/lib/typescript/module/agent-creator/CyclopsArtwork.d.ts +0 -5
  213. package/lib/typescript/module/agent-creator/CyclopsArtwork.d.ts.map +0 -1
  214. package/lib/typescript/module/agent-creator/ToddArtwork.d.ts +0 -6
  215. package/lib/typescript/module/agent-creator/ToddArtwork.d.ts.map +0 -1
  216. package/src/agent-avatar/character-colors.ts +0 -12
  217. package/src/agent-avatar/character-shapes.ts +0 -122
  218. package/src/agent-creator/ClippoArtwork.tsx +0 -49
  219. package/src/agent-creator/CyclopsArtwork.tsx +0 -27
  220. package/src/agent-creator/ToddArtwork.tsx +0 -24
@@ -1,147 +1,48 @@
1
1
  // Optional renderer adapter. The generated engine and data alongside it remain unmodified.
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';
2
+ import createModule from './orbit-characters.mjs';
3
+ import { customizeAppearance, encodeAppearance } from './appearance-codec.mjs';
4
+ import { acquireLegacyEngine, legacyEngineStats } from './legacy-engine.mjs';
13
5
  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';
34
6
 
7
+ let modulePromise;
8
+ let serial = 0;
35
9
  let frame = 0;
36
- let rendering = false;
10
+ let lastFrame = -Infinity;
37
11
  let nextClient = 0;
38
12
  let lastRenderMilliseconds = 0;
39
- let lastRenderWaitMilliseconds = 0;
40
- let renderCost = 8;
41
- let attentionTurn = false;
42
13
  const clients = new Set();
43
14
  const category = { shape: 0, color: 1, eyes: 2, eyewear: 3, accessory: 4 };
44
- const appearanceFingerprint = (recipe) => {
45
- const { eyeSpacing: _historicalSpacing, ...appearance } = recipe;
46
- return JSON.stringify(appearance);
47
- };
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
+ }
48
23
  function schedule() {
49
- if (!frame && !rendering && clients.size && !document.hidden)
24
+ if (!frame && clients.size && !document.hidden)
50
25
  frame = requestAnimationFrame(tick);
51
26
  }
52
- async function tick(time) {
27
+ function tick(time) {
53
28
  frame = 0;
54
29
  if (document.hidden) return;
55
- rendering = true;
56
- try {
30
+ if (time - lastFrame >= 1000 / 30 - 1) {
31
+ lastFrame = time;
57
32
  const batch = [...clients];
58
33
  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] : [];
78
34
  for (let i = 0; i < batch.length; i++) {
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);
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;
140
42
  }
141
- } finally {
142
- rendering = false;
143
- schedule();
43
+ lastRenderMilliseconds = performance.now() - started;
144
44
  }
45
+ schedule();
145
46
  }
146
47
  function visibility() {
147
48
  for (const client of instances) client.visibility();
@@ -149,7 +50,7 @@ function visibility() {
149
50
  }
150
51
  const instances = new Set();
151
52
 
152
- /** Original engine and shared animation clock; scoped geometry identities stay distinct. */
53
+ /** Original engine and shared animation clock; contour caches are isolated by legacy-engine. */
153
54
  export async function createAvatar(canvas, initial, callbacks = {}) {
154
55
  if (!initial.config.character && !initial.legacy)
155
56
  throw new Error('A recovered character recipe is required');
@@ -157,7 +58,9 @@ export async function createAvatar(canvas, initial, callbacks = {}) {
157
58
  return createManagedAvatar(canvas, initial, callbacks, createCharacter);
158
59
  }
159
60
  function createCharacter(canvas, initial, callbacks) {
160
- return createScopedCharacter(canvas, initial, callbacks);
61
+ return initial.legacy
62
+ ? createLegacyCharacter(canvas, initial, callbacks)
63
+ : createLiveCharacter(canvas, initial, callbacks);
161
64
  }
162
65
 
163
66
  async function createLiveCharacter(
@@ -167,41 +70,34 @@ async function createLiveCharacter(
167
70
  isCurrent = () => true,
168
71
  previousImage = null,
169
72
  ) {
170
- const authoredParts = authoredPartsFor(initial);
171
73
  const lease = initial.legacy?.points
172
- ? await acquireLegacyEngine(initial.legacy.points, authoredParts)
74
+ ? await acquireLegacyEngine(initial.legacy.points)
173
75
  : null;
174
- const module = lease?.module ?? (await getCharacterEngine(authoredParts));
175
- let releasePreparation = await acquireCharacterPreparation();
176
- await waitForSharedRender();
76
+ const module = lease?.module ?? (await engine());
177
77
  if (!isCurrent() || !canvas.isConnected) {
178
- releasePreparation();
179
78
  lease?.release();
180
79
  return { update() {}, dispose() {}, diagnostics: () => ({ ready: false }) };
181
80
  }
81
+ const id = `bloom-character-${++serial}`;
182
82
  const output = canvas.getContext('2d');
183
83
  if (!output) {
184
- releasePreparation();
185
84
  lease?.release();
186
85
  throw new Error('Character output canvas is unavailable');
187
86
  }
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;
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();
197
96
  let character;
198
97
  try {
199
- character = surfaceLease.create(module, 128, 128, {
200
- activities: supportsActivities,
201
- });
98
+ character = new module.Character(`#${id}`, 128, 128);
202
99
  } catch (error) {
203
- releasePreparation();
204
- surfaceLease.release();
100
+ surface.remove();
205
101
  lease?.release();
206
102
  throw error;
207
103
  }
@@ -222,9 +118,6 @@ async function createLiveCharacter(
222
118
  episode = 0n,
223
119
  activity = 0;
224
120
  let workTimer,
225
- workRequest = 0,
226
- stoppingWork = false,
227
- queuedReaction = false,
228
121
  workDuration,
229
122
  lastActivityResult,
230
123
  previousWork,
@@ -232,64 +125,7 @@ async function createLiveCharacter(
232
125
  let pointerId, pointerStart, previousReaction;
233
126
  let lastReaction = null,
234
127
  lastReactionKind = null;
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.
128
+ character.setQuality(initial.portrait ? 1 : 0); // Original automatic quality; compact static thumbnails.
293
129
  // Readiness means painted pixels, not merely an engine submission.
294
130
  const play = (kind) => {
295
131
  if (activity === kind) return;
@@ -310,39 +146,91 @@ async function createLiveCharacter(
310
146
  // A queued still must finish if its selector moves it offscreen mid-prepare;
311
147
  // otherwise one clipped slot blocks every visible thumbnail until timeout.
312
148
  const active = () => (visible || props.portrait) && !document.hidden;
313
- let wakeQueued = false;
314
149
  const wake = () => {
315
150
  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
- }
326
151
  character.setActive(active());
327
152
  if (active() && (dirty || !ready || animate())) clients.add(client);
328
153
  else clients.delete(client);
329
154
  schedule();
330
155
  };
331
156
  const client = {
332
- preparing: () => Boolean(releasePreparation),
333
- attention: (time) => (time < attentionUntil ? attentionStarted : 0),
334
157
  visibility: wake,
335
- fail(error) {
336
- clients.delete(client);
337
- finishPreparation(true);
338
- callbacks.onError?.(error);
339
- },
340
158
  render(time) {
341
159
  try {
342
- const submitted = surfaceLease.render(
343
- () => character.render(time),
344
- scenePrepared,
345
- );
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
+ }
346
234
  const renderError = character.renderError();
347
235
  if (renderError || character.preparationStats().failed)
348
236
  throw new Error(
@@ -350,293 +238,113 @@ async function createLiveCharacter(
350
238
  character.preparationError() ||
351
239
  'Character preparation failed',
352
240
  );
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);
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);
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?.();
479
252
  }
480
- };
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);
265
+ }
481
266
  } catch (error) {
482
267
  clients.delete(client);
483
- finishPreparation();
484
268
  callbacks.onError?.(error);
485
269
  }
486
270
  },
487
271
  };
488
- let currentQuality, currentScale;
489
272
  const resize = () => {
490
- const cssSize = Math.max(
491
- 1,
492
- Math.min(canvas.clientWidth, canvas.clientHeight),
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))),
493
277
  );
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);
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();
541
287
  };
542
288
  const switchMode = (next, resetAppearance = false) => {
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
- }
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 });
565
295
  activityMode = next;
296
+ fit = null;
297
+ sequence = 0n;
298
+ episode = 0n;
299
+ activity = 0;
300
+ ready = false;
566
301
  dirty = true;
567
- };
568
- const attemptReaction = () => {
569
- lastReactionKind = 2;
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;
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();
579
320
  };
580
321
  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();
322
+ if (!ready || !animate()) return;
323
+ if (activityMode) return;
324
+ 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();
597
334
  };
598
335
  const work = () => {
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;
336
+ if (!animate()) return;
602
337
  clearTimeout(workTimer);
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);
338
+ switchMode(true);
339
+ play(1);
340
+ workTimer = undefined;
341
+ workDuration = 2200 * Math.max(1, Math.min(10, props.workingCycles || 1));
624
342
  };
343
+ let appearanceTimer, pendingFingerprint;
625
344
  const applyAppearance = (value) => {
626
345
  const recipe = value;
627
- const nextFingerprint = appearanceFingerprint(recipe);
346
+ const nextFingerprint = JSON.stringify(recipe);
628
347
  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;
640
348
  // Catalog selection owns the engine's part transitions. Restoring the
641
349
  // named preset on every selection needlessly resets the whole character.
642
350
  const removedOverride = Object.keys(appliedRecipe?.selections ?? {}).some(
@@ -648,74 +356,28 @@ async function createLiveCharacter(
648
356
  removedOverride ||
649
357
  (appliedRecipe.bodyColor && !recipe.bodyColor)
650
358
  ) {
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
- }
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);
686
375
  if (character.restore(base))
687
376
  throw new Error('Invalid character appearance');
688
377
  }
689
378
  for (const key of Object.keys(category)) {
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
- }
379
+ const value = recipe.selections?.[key];
380
+ if (!value) continue;
719
381
  if (key === 'accessory' && value === 'none') {
720
382
  for (const item of module.catalog(4))
721
383
  if (character.isSelected(4, item.id)) character.select(4, item.id);
@@ -728,10 +390,8 @@ async function createLiveCharacter(
728
390
  throw new Error(`Unsupported character selection: ${key}`);
729
391
  }
730
392
  const patch =
731
- (isLegacyRecipe(props) ? props.legacy?.patch : undefined) ??
732
- (recipe.bodyColor || implicitBodyColor
733
- ? { bodyColor: recipe.bodyColor ?? implicitBodyColor }
734
- : null);
393
+ props.legacy?.patch ??
394
+ (recipe.bodyColor ? { bodyColor: recipe.bodyColor } : null);
735
395
  if (patch) {
736
396
  // One authored preset uses an unnamed color. Enter the engine's normal
737
397
  // editable palette before replacing that paint with an explicit RGB.
@@ -769,107 +429,34 @@ async function createLiveCharacter(
769
429
  const selected = {};
770
430
  for (const [key, index] of Object.entries(category)) {
771
431
  for (const item of module.catalog(index)) {
772
- available[`${key}:${item.id}`] = NATIVE_PARTS[key]?.includes(item.id)
773
- ? true
774
- : character.isAvailable(index, item.id);
432
+ available[`${key}:${item.id}`] = character.isAvailable(
433
+ index,
434
+ item.id,
435
+ );
775
436
  if (character.isSelected(index, item.id)) selected[key] = item.id;
776
437
  }
777
438
  }
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';
814
439
  available['accessory:none'] = true;
815
440
  selected.accessory ??= 'none';
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);
441
+ callbacks.onCapabilities?.({ key: fingerprint, available, selected });
842
442
  dirty = true;
843
443
  }
844
444
  };
845
- let deferredUpdate;
846
445
  const update = (next, silent = false) => {
847
446
  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
- }
859
447
  props = next;
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);
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);
873
460
  // Dragging the shared color picker may emit dozens of values per second.
874
461
  // Prepare only its latest value while retaining the existing painted frame.
875
462
  if (pendingFingerprint !== nextFingerprint) {
@@ -883,19 +470,13 @@ async function createLiveCharacter(
883
470
  appearanceTimer = setTimeout(() => {
884
471
  if (disposed) return;
885
472
  try {
886
- prepare(() => {
887
- if (pendingFingerprint === nextFingerprint)
888
- applyAppearance(recipe);
889
- });
473
+ applyAppearance(recipe);
890
474
  wake();
891
475
  } catch (error) {
892
476
  callbacks.onError?.(error);
893
477
  }
894
478
  }, 80);
895
- } else
896
- prepare(() => {
897
- if (pendingFingerprint === nextFingerprint) applyAppearance(recipe);
898
- });
479
+ } else applyAppearance(recipe);
899
480
  }
900
481
  // The recovered engine needs its first appearance rendered with this off.
901
482
  // Our scheduler freezes the completed frame instead.
@@ -939,18 +520,6 @@ async function createLiveCharacter(
939
520
  dirty = true;
940
521
  wake();
941
522
  };
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
- }
954
523
  function pointer(event) {
955
524
  if (!props.interactive || !animate()) return;
956
525
  const phase = {
@@ -961,11 +530,8 @@ async function createLiveCharacter(
961
530
  }[event.type];
962
531
  if (event.type === 'pointerdown') {
963
532
  if (event.button !== 0 || pointerId != null || !event.isPrimary) return;
964
- attend(3000);
965
533
  pointerStart = [event.clientX, event.clientY];
966
534
  pointerId = event.pointerId;
967
- // Capture while the physical pointer is still down. GPU completion may
968
- // arrive after pointerup, when setPointerCapture would throw instead.
969
535
  canvas.setPointerCapture(pointerId);
970
536
  }
971
537
  if (pointerId != null && pointerId !== event.pointerId) return;
@@ -975,41 +541,38 @@ async function createLiveCharacter(
975
541
  localY = (event.clientY - r.top) / r.height;
976
542
  const x = fit ? (fit[0] + localX * fit[2]) / canvas.width : localX;
977
543
  const y = fit ? (fit[1] + localY * fit[2]) / canvas.height : localY;
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
- });
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);
994
548
  if (phase === 2 || phase === 3) {
995
- if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
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);
996
560
  pointerId = undefined;
997
- pointerStart = undefined;
998
561
  }
562
+ dirty = true;
563
+ wake();
999
564
  }
1000
565
  const cancel = () => {
1001
- const id = pointerId;
566
+ if (pointerId != null)
567
+ character.pointer(3, pointerId, 0.5, 0.5, performance.now() / 1000);
1002
568
  pointerId = undefined;
1003
569
  pointerStart = undefined;
1004
- if (id != null)
1005
- input(() => character.pointer(3, id, 0.5, 0.5, performance.now() / 1000));
1006
570
  };
1007
571
  const leave = () => {
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
- });
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
+ }
1013
576
  };
1014
577
  canvas.addEventListener('lostpointercapture', cancel);
1015
578
  canvas.addEventListener('pointerleave', leave);
@@ -1039,14 +602,9 @@ async function createLiveCharacter(
1039
602
  canvas.removeEventListener('lostpointercapture', cancel);
1040
603
  canvas.removeEventListener('pointerleave', leave);
1041
604
  window.removeEventListener('blur', cancel);
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();
605
+ character.delete();
606
+ surface.remove();
607
+ lease?.release();
1050
608
  if (!instances.size) {
1051
609
  document.removeEventListener('visibilitychange', visibility);
1052
610
  cancelAnimationFrame(frame);
@@ -1056,7 +614,6 @@ async function createLiveCharacter(
1056
614
  try {
1057
615
  update(initial);
1058
616
  resize();
1059
- bindController();
1060
617
  } catch (error) {
1061
618
  dispose();
1062
619
  throw error;
@@ -1071,21 +628,18 @@ async function createLiveCharacter(
1071
628
  lastReactionKind,
1072
629
  lastActivityResult,
1073
630
  activityMode,
1074
- stoppingWork,
1075
- queuedReaction,
1076
631
  ready,
1077
632
  pending:
1078
633
  dirty ||
1079
- acquiringPreparation ||
1080
- preparationQueue.length > 0 ||
1081
- !scenePrepared(),
634
+ Boolean(character.preparationStats().pending) ||
635
+ character.hasPendingUpdate(),
1082
636
  }),
1083
637
  };
1084
638
  }
1085
639
 
1086
640
  // A geometry cache must never serve a different silhouette under the same
1087
- // appearance key. Keep the old painted image while replacing the scoped character.
1088
- async function createScopedCharacter(canvas, initial, callbacks) {
641
+ // appearance key. Keep the old painted image while switching contour modules.
642
+ async function createLegacyCharacter(canvas, initial, callbacks) {
1089
643
  let controller,
1090
644
  shapeKey,
1091
645
  revision = 0,
@@ -1093,12 +647,8 @@ async function createScopedCharacter(canvas, initial, callbacks) {
1093
647
  latest = initial;
1094
648
  const update = async (next, silent = false) => {
1095
649
  if (disposed) return;
1096
- next = withCharacterGeometry(next);
1097
650
  latest = next;
1098
- const key = JSON.stringify([
1099
- next.legacy?.points ?? null,
1100
- authoredPartsFor(next),
1101
- ]);
651
+ const key = JSON.stringify(next.legacy.points ?? null);
1102
652
  if (shapeKey === key) {
1103
653
  controller?.update(next, silent);
1104
654
  return;
@@ -1158,8 +708,7 @@ async function createScopedCharacter(canvas, initial, callbacks) {
1158
708
  },
1159
709
  react: () => controller?.react(),
1160
710
  work: () => controller?.work(),
1161
- diagnostics: () =>
1162
- controller?.diagnostics() ?? { ready: false, pending: !disposed },
711
+ diagnostics: () => controller?.diagnostics(),
1163
712
  };
1164
713
  }
1165
714
 
@@ -1174,7 +723,16 @@ function createPortrait(canvas, initial, callbacks) {
1174
723
  cancel;
1175
724
  const update = (props) => {
1176
725
  if (disposed) return;
1177
- const pixels = characterPixels(canvas);
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
+ );
1178
736
  const key = JSON.stringify([
1179
737
  props.config.character,
1180
738
  props.legacy,
@@ -1220,18 +778,6 @@ function createPortrait(canvas, initial, callbacks) {
1220
778
  canvas,
1221
779
  { ...props, paused: true, interactive: false },
1222
780
  {
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
- },
1235
781
  onCapabilities(value) {
1236
782
  capabilities = value;
1237
783
  if (current()) callbacks.onCapabilities?.(value);
@@ -1259,7 +805,14 @@ function createPortrait(canvas, initial, callbacks) {
1259
805
  if (finished || !current()) {
1260
806
  controller.dispose();
1261
807
  finish();
1262
- }
808
+ } else
809
+ timer = setTimeout(() => {
810
+ if (current())
811
+ callbacks.onError?.(
812
+ new Error('Character preview preparation timed out'),
813
+ );
814
+ finish();
815
+ }, 20000);
1263
816
  await completion;
1264
817
  } catch (error) {
1265
818
  if (current()) callbacks.onError?.(error);
@@ -1288,12 +841,10 @@ const registryKey = Symbol.for('bloom.character.runtimes');
1288
841
  export function runtimeStats() {
1289
842
  return {
1290
843
  renderMilliseconds: lastRenderMilliseconds,
1291
- renderWaitMilliseconds: lastRenderWaitMilliseconds,
1292
844
  budget: managedAvatarStats(),
1293
- surface: sharedSurfaceStats(),
1294
845
  instances: instances.size,
1295
846
  active: clients.size,
1296
- scheduled: !!frame || rendering,
847
+ scheduled: !!frame,
1297
848
  cachedPortraits: portraits.size,
1298
849
  legacy: legacyEngineStats(),
1299
850
  };