@volter/editor-blender 0.1.143 → 0.1.144

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.
@@ -20,7 +20,9 @@
20
20
  * The file's own NLA strips play on the game's clock as scene frames, looping the scene's range as
21
21
  * Blender's own playback does. Every
22
22
  * clip is baked once per action, the first time it is wanted; until the clips a pose needs are
23
- * baked, the armature keeps the pose it has.
23
+ * baked, the armature keeps the pose it has. The clips each character STARTS on are baked before
24
+ * the game starts (`prepare`), and a bake that could not be read is tried again, so a slow or
25
+ * failed read at Play's start does not leave a character frozen for the run.
24
26
  *
25
27
  * THE GAME'S CLOCK DRIVES IT: the runner advances it by each update's `dt`
26
28
  * (`@volter/play`'s `play-script.ts`), so pause, step, speed and Restart hold for
@@ -74,6 +76,22 @@ export interface PlayAnimation {
74
76
  track(object: THREE.Object3D, name: string, options: PlayTrackOptions | null): Answer;
75
77
  constraint(object: THREE.Object3D, bone: string, name: string, options: PlayConstraintOptions): Answer;
76
78
  update(dt: number): void;
79
+ /**
80
+ * THE LOADING PHASE: bakes every clip a character starts on (its assigned action and its NLA
81
+ * strips' actions) before the game's first update, so no character stands frozen while Blender
82
+ * answers. Resolves with what could not be loaded; clips the game asks for later still bake on
83
+ * first use.
84
+ */
85
+ prepare(): Promise<{ readonly failed: readonly string[] }>;
86
+ /** The starting clips `prepare` is still waiting on Blender for, as `armature / action`. */
87
+ pending(): readonly string[];
88
+ /**
89
+ * THE CLIP LIBRARY, read in the background once the game runs: every action on each armature's
90
+ * NLA strips, muted tracks included (a game's clip library), most-shared first, one read at a
91
+ * time and only while no other read is waiting, so a clip the game asks for waits behind at most
92
+ * one. Without it the first switch to a library clip stood the character still for a read.
93
+ */
94
+ prefetch(): void;
77
95
  /** What each character is playing now, bottom layer first: the animation editors' live view. */
78
96
  live(): readonly LiveArmature[];
79
97
  /** The frames an action keys, per bone, for one armature (empty until it was baked). */
@@ -124,7 +142,10 @@ interface Armature {
124
142
  }
125
143
 
126
144
  /** The copy's animation. `bake` reads one action of one armature through the clip door. */
127
- export function playAnimation(view: BlenderRuntimeView, bake: (armature: string, action: string) => Promise<BlenderActionClip | null>): PlayAnimation {
145
+ /** Reads that outlive one Play: a document's next Play reuses them (see `shared` below). */
146
+ export type PlayClipCache = Map<string, Promise<BlenderActionClip | null>>;
147
+
148
+ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string, action: string) => Promise<BlenderActionClip | null>, cache: PlayClipCache = new Map()): PlayAnimation {
128
149
  const warnings: string[] = [];
129
150
  const facts = view.animationFacts();
130
151
  const actions = Object.keys(facts.actions);
@@ -141,17 +162,90 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
141
162
  armature.failed.set(action, why);
142
163
  warnings.push(`${armature.rig.armature} / ${action}: ${why}`);
143
164
  };
165
+ /** Reads that failed (Blender busy, the call refused) per clip: tried again up to this many times. */
166
+ const READ_ATTEMPTS = 3;
167
+ const attempts = new Map<string, number>();
168
+ const baking = new Map<string, Promise<void>>();
169
+ /**
170
+ * ONE BAKE PER ACTION AND SKELETON. A bake depends on the action, the armature's bones and the
171
+ * action slot Blender picks for it (`rna_action_clip`), never on the armature otherwise; each
172
+ * read holds the Blender worker for about 1.5 s. Read per armature, fifteen enemies placed from
173
+ * one piece baked the same clip fifteen times, and an action the game switched one to waited
174
+ * behind all of them: measured on Heck Plungers, half the characters still unposed 20 s in with
175
+ * Blender idle. So armatures with the same bones share a read when the action has at most one
176
+ * object slot (`objectSlots`, from Blender): with several, which slot plays depends on the
177
+ * armature, and each armature reads its own.
178
+ */
179
+ // KEPT ACROSS PLAYS by the caller (`cache`): keyed by the action's REVISION too, so an action
180
+ // edited between Plays is read again and an unchanged one is not read twice.
181
+ const shared = cache;
182
+ /** A clip whose slot Blender picked for its armature among several: never another armature's answer. */
183
+ const ownSlot = (clip: BlenderActionClip | null): boolean => !!clip && (clip.objectSlots ?? 2) > 1;
184
+ const readClip = (armature: Armature, action: string): Promise<BlenderActionClip | null> => {
185
+ const name = armature.rig.armature;
186
+ const skeleton = armature.facts.bones.map((bone) => bone.name).sort().join('\u0001');
187
+ const key = `${facts.actions[action] ?? ''}\u0000${action}\u0000${skeleton}`;
188
+ const known = shared.get(key);
189
+ if (known) return known.then((clip) => (ownSlot(clip) && clip!.armature !== name ? bake(name, action) : clip));
190
+ const read = bake(name, action).then((clip) => {
191
+ if (ownSlot(clip)) shared.delete(key);
192
+ return clip;
193
+ }, (error: unknown) => {
194
+ shared.delete(key);
195
+ throw error;
196
+ });
197
+ shared.set(key, read);
198
+ return read;
199
+ };
144
200
  const clipOf = (armature: Armature, action: string): PoseClip | null | undefined => {
145
201
  if (armature.clips.has(action)) return armature.clips.get(action);
146
- armature.clips.set(action, undefined);
147
- void bake(armature.rig.armature, action).then((baked) => {
148
- const clip = baked ? poseClip(baked) : null;
149
- for (const thing of clip?.unsupported ?? []) warnings.push(`${armature.rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
150
- if (clip) armature.clips.set(action, clip);
151
- else fail(armature, action, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
152
- }, (error: unknown) => fail(armature, action, `it could not be read: ${error instanceof Error ? error.message : String(error)}`));
202
+ void bakeClip(armature, action);
153
203
  return undefined;
154
204
  };
205
+ const bakeClip = (armature: Armature, action: string): Promise<void> => {
206
+ const key = `${armature.rig.armature}\u0000${action}`;
207
+ const running = baking.get(key);
208
+ if (running) return running;
209
+ if (armature.clips.has(action) && armature.clips.get(action) !== undefined) return Promise.resolve();
210
+ armature.clips.set(action, undefined);
211
+ const done = readClip(armature, action).then((baked) => {
212
+ // A clip that cannot be posed is that clip's failure, named like the others; it never
213
+ // rejects the bake, so the loading phase that awaits it always settles.
214
+ try {
215
+ const clip = baked ? poseClip(baked) : null;
216
+ for (const thing of clip?.unsupported ?? []) warnings.push(`${armature.rig.armature}: ${thing} plays only in Blender, not in a game.${remedy([thing])}`);
217
+ if (clip) armature.clips.set(action, clip);
218
+ else fail(armature, action, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
219
+ } catch (error) {
220
+ fail(armature, action, `it could not be posed: ${error instanceof Error ? error.message : String(error)}`);
221
+ }
222
+ }, (error: unknown) => {
223
+ const tried = (attempts.get(key) ?? 0) + 1;
224
+ attempts.set(key, tried);
225
+ const why = `it could not be read: ${error instanceof Error ? error.message : String(error)}`;
226
+ // A READ THAT FAILED is not the clip's answer: the next pose asks again, up to READ_ATTEMPTS.
227
+ if (tried < READ_ATTEMPTS && !disposed) armature.clips.delete(action);
228
+ else fail(armature, action, why);
229
+ }).finally(() => baking.delete(key));
230
+ baking.set(key, done);
231
+ return done;
232
+ };
233
+
234
+ /**
235
+ * Each armature's assigned action and the strips' actions of the NLA tracks it plays (enabled as
236
+ * `layersOf` reads them: NLA on, under a solo only the soloed track, else the unmuted ones): the
237
+ * clips it starts on. A muted track's strips are a clip library, baked when the game asks.
238
+ */
239
+ const startingClips = (): { armature: Armature; action: string }[] => [...armatures.values()].flatMap((armature) => {
240
+ const names = new Set<string>();
241
+ if (armature.facts.action) names.add(armature.facts.action);
242
+ const animation = armature.facts.animation;
243
+ const solo = animation?.tracks.some((track) => track.solo) ?? false;
244
+ for (const track of animation && animation.useNla ? animation.tracks : [])
245
+ if (solo ? track.solo : !track.mute)
246
+ for (const strip of track.strips) if (strip.action) names.add(strip.action);
247
+ return [...names].filter((name) => name in facts.actions).map((action) => ({ armature, action }));
248
+ });
155
249
 
156
250
  for (const rig of view.skeletons.rigs()) {
157
251
  const entry = facts.armatures[rig.armature];
@@ -331,6 +425,43 @@ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string,
331
425
  armature.constraints.set(key, { ...armature.constraints.get(key), ...options });
332
426
  return ok(armature);
333
427
  },
428
+ async prepare() {
429
+ const wanted = startingClips();
430
+ for (let round = 0; round < READ_ATTEMPTS && !disposed; round += 1) {
431
+ const open = wanted.filter(({ armature, action }) => armature.clips.get(action) === undefined);
432
+ if (open.length === 0) break;
433
+ await Promise.all(open.map(({ armature, action }) => bakeClip(armature, action)));
434
+ }
435
+ const failed = wanted
436
+ .filter(({ armature, action }) => armature.clips.get(action) == null && armature.failed.has(action) && !/animates none/.test(armature.failed.get(action) ?? ''))
437
+ .map(({ armature, action }) => `${armature.rig.armature} / ${action}: ${armature.failed.get(action)}`);
438
+ return { failed };
439
+ },
440
+ prefetch() {
441
+ const users = new Map<string, Armature[]>();
442
+ for (const armature of armatures.values()) {
443
+ const names = new Set<string>();
444
+ if (armature.facts.action) names.add(armature.facts.action);
445
+ for (const track of armature.facts.animation?.tracks ?? [])
446
+ for (const strip of track.strips) if (strip.action) names.add(strip.action);
447
+ for (const action of names) if (action in facts.actions) users.set(action, [...(users.get(action) ?? []), armature]);
448
+ }
449
+ const queue = [...users].sort((a, b) => b[1].length - a[1].length)
450
+ .flatMap(([action, list]) => list.map((armature) => ({ armature, action })));
451
+ void (async () => {
452
+ while (!disposed) {
453
+ if (baking.size > 0) { await Promise.allSettled([...baking.values()]); continue; }
454
+ const next = queue.find(({ armature, action }) => !armature.clips.has(action));
455
+ if (!next) return;
456
+ await bakeClip(next.armature, next.action);
457
+ }
458
+ })();
459
+ },
460
+ pending() {
461
+ return startingClips()
462
+ .filter(({ armature, action }) => armature.clips.get(action) === undefined && !armature.failed.has(action))
463
+ .map(({ armature, action }) => `${armature.rig.armature} / ${action}`);
464
+ },
334
465
  update(dt) {
335
466
  if (disposed) return;
336
467
  time += dt;
@@ -77,7 +77,10 @@ import {
77
77
  import { BlenderObjectModeHeader } from './blender-header-menus';
78
78
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
79
79
  import { blenderSkin } from './blender-runtime-skin';
80
- import { playAnimation, type PlayAnimation } from './blender-play-skin';
80
+ import { playAnimation, type PlayAnimation, type PlayClipCache } from './blender-play-skin';
81
+
82
+ /** Clip reads per model document, kept across Plays (`PlayClipCache`). */
83
+ const playClipCaches = new Map<string, PlayClipCache>();
81
84
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
82
85
  import { noteModelDocument } from '../src/play-mode';
83
86
  import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/sdk/kit/document-play-extension';
@@ -912,9 +915,69 @@ function BlenderViewportArea({
912
915
  // THE COPY'S CHARACTERS ANIMATE: its view bound their skins from the copied frame, and each
913
916
  // armature plays the action the file assigns it until the game sets another
914
917
  // (`blender-play-skin.ts`). Nothing is read before the game starts.
915
- void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
918
+ // A LOADING PHASE, said on screen: the game starts only once its stage is drawn and every
919
+ // character's starting clips are baked (`PlayAnimation.prepare`). Started before them, a
920
+ // character whose clip Blender had not answered yet stood frozen in its rest pose, and a
921
+ // clip whose read failed stayed frozen for the whole run. That is the suspected cause of
922
+ // "sometimes none of the animations work" (a busy Blender was measured to delay the clips).
923
+ // THE WAIT IS BOUNDED AND SAID: after LOADING_SAY the overlay names the clips still
924
+ // awaited; at LOADING_LIMIT the game starts without them and the console names them (each
925
+ // still plays once Blender answers). A thrown `prepare` is named too; the game always starts.
926
+ const loading = window.document.createElement('div');
927
+ loading.dataset['testid'] = 'model-play-loading';
928
+ loading.textContent = 'Loading…';
929
+ Object.assign(loading.style, {
930
+ position: 'absolute', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center',
931
+ background: 'rgba(0, 0, 0, 0.35)', color: '#e6e6e6', font: '600 14px system-ui, sans-serif', pointerEvents: 'none',
932
+ });
933
+ layers.appendChild(loading);
934
+ void view.prepareRendered(stage.rig().drawCamera(), 'render').then(async () => {
916
935
  if (stopped) return;
917
- animation = playAnimation(view, (armature, action) => blenderActionClip({ object: armature, action }));
936
+ // No session answers null: a read that failed, asked again, not a clip that animates nothing.
937
+ // A SETUP THAT THROWS still starts the game, without animation and said so; left
938
+ // outside any catch it left the loading overlay up and the script never ran.
939
+ let cache = playClipCaches.get(modelId);
940
+ if (!cache) playClipCaches.set(modelId, cache = new Map());
941
+ let loaded: PlayAnimation | null = null;
942
+ try {
943
+ loaded = playAnimation(view, async (armature, action) => {
944
+ const clip = await blenderActionClip({ object: armature, action, summary: false });
945
+ if (clip === null) throw new Error("Blender's session is not started");
946
+ return clip;
947
+ }, cache);
948
+ } catch (error) {
949
+ editorHost().console.error(`The game's animation could not be set up, so the game starts without it: ${error instanceof Error ? error.message : String(error)}`, 'blender-animation');
950
+ }
951
+ animation = loaded;
952
+ if (!loaded) {
953
+ loading.remove();
954
+ if (stopped) return;
955
+ }
956
+ if (loaded) {
957
+ const anim = loaded;
958
+ const LOADING_SAY = 3_000, LOADING_LIMIT = 20_000;
959
+ const clips = (names: readonly string[]): string => `${names.length} clip${names.length === 1 ? '' : 's'}`;
960
+ const say = window.setInterval(() => {
961
+ const pending = anim.pending();
962
+ if (pending.length > 0) loading.textContent = `Loading… waiting on Blender for ${clips(pending)}: ${pending.slice(0, 6).join(', ')}${pending.length > 6 ? ` and ${pending.length - 6} more` : ''}`;
963
+ }, LOADING_SAY);
964
+ let limit = 0;
965
+ const outcome = await Promise.race([
966
+ anim.prepare().then(({ failed }) => ({ failed, waiting: [] as readonly string[] }), (error: unknown) => ({ failed: [`the loading phase failed: ${error instanceof Error ? error.message : String(error)}`], waiting: [] as readonly string[] })),
967
+ new Promise<{ failed: readonly string[]; waiting: readonly string[] }>((resolve) => {
968
+ limit = window.setTimeout(() => resolve({ failed: [], waiting: anim.pending() }), LOADING_LIMIT);
969
+ }),
970
+ ]);
971
+ window.clearInterval(say);
972
+ window.clearTimeout(limit);
973
+ loading.remove();
974
+ if (stopped) return;
975
+ if (outcome.failed.length > 0)
976
+ editorHost().console.warn(`The game started without ${clips(outcome.failed)} it starts on: ${outcome.failed.join('; ')}`, 'blender-animation');
977
+ if (outcome.waiting.length > 0)
978
+ editorHost().console.warn(`The game started after ${LOADING_LIMIT / 1000} s still waiting on Blender for ${clips(outcome.waiting)}; each plays once Blender answers: ${outcome.waiting.join(', ')}`, 'blender-animation');
979
+ anim.prefetch();
980
+ }
918
981
  setLiveAnimation(animation);
919
982
  stopScript = documentPlayExtension('model')?.run({
920
983
  ...(animation ? { animation } : {}),
@@ -934,6 +997,7 @@ function BlenderViewportArea({
934
997
  },
935
998
  }) ?? null;
936
999
  }, error => {
1000
+ loading.remove();
937
1001
  if (stopped) return;
938
1002
  const failure = `The game's stage could not be prepared for a rendered draw: ${error instanceof Error ? error.message : String(error)}`;
939
1003
  editorHost().console.error(failure, 'model-play');
@@ -1061,7 +1125,7 @@ function BlenderViewportArea({
1061
1125
  let live = true;
1062
1126
  void blenderSkin.sync(view, {
1063
1127
  clip: (armature) => blenderActionClip({ object: armature }),
1064
- bake: (armature, action) => blenderActionClip({ object: armature, action }),
1128
+ bake: (armature, action) => blenderActionClip({ object: armature, action, summary: false }),
1065
1129
  }).catch((thrown: unknown) => {
1066
1130
  if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
1067
1131
  });
@@ -676,6 +676,8 @@ export async function blenderActionClip(options?: {
676
676
  bake?: boolean;
677
677
  /** A named action baked against `object`'s armature, assigned or not. */
678
678
  action?: string;
679
+ /** `false` skips the Timeline's summary row, which a reader that only plays the clip never draws. */
680
+ summary?: boolean;
679
681
  }): Promise<BlenderActionClip | null> {
680
682
  if (!blenderSessionStarted()) return null;
681
683
  return blenderRuntime().actionClip(options);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/editor-blender",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "AGPL-3.0-only AND GPL-3.0-or-later",
5
- "version": "0.1.143",
5
+ "version": "0.1.144",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -72,15 +72,15 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/sdk": "1.32.1",
75
- "@volter/blender-engine": "0.1.141",
76
- "@volter/editor-threejs": "0.5.204",
75
+ "@volter/blender-engine": "0.1.142",
76
+ "@volter/editor-threejs": "0.5.205",
77
77
  "zod": "^4.3.6",
78
78
  "@fortawesome/free-solid-svg-icons": "^7.2.0"
79
79
  },
80
80
  "peerDependencies": {
81
- "@volter/live": "0.5.204",
82
- "@volter/project": "0.5.204",
83
- "@volter/sdk": "0.5.204",
81
+ "@volter/live": "0.5.205",
82
+ "@volter/project": "0.5.205",
83
+ "@volter/sdk": "0.5.205",
84
84
  "react": "^19.0.0",
85
85
  "react-dom": "^19.0.0",
86
86
  "three": "^0.180.0",