@volter/editor-blender 0.1.72 → 0.1.74

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.
@@ -217,6 +217,7 @@ export class BlenderSkinDirector {
217
217
  * frame stands, which is Blender's answer for a file with no animation. */
218
218
  frame(): number {
219
219
  const clip = this.#clip;
220
+ if (this.#seeked !== null) return this.#seeked;
220
221
  if (!clip || !this.#action || clip.clipStart === undefined) return clip?.frameCurrent ?? 1;
221
222
  // A SEEK'S OWN ANSWER, while one is standing. `LoopRepeat` wraps
222
223
  // `action.time` into `[0, duration)`, so a seek to the LAST frame lands on
@@ -224,7 +225,6 @@ export class BlenderSkinDirector {
224
225
  // walk, where `jump-end` answered frame 1 over a frame-48 pose. The
225
226
  // wrapping is right for playback and wrong for a question, so a standing
226
227
  // seek answers with the frame it was given and the play tick clears it.
227
- if (this.#seeked !== null) return this.#seeked;
228
228
  // THE ACTION'S TIME, NOT THE MIXER'S, and the difference is the whole of
229
229
  // looping: `AnimationMixer.time` is monotonic and never wraps, while
230
230
  // `AnimationAction.time` is wrapped into `[0, duration]` by `LoopRepeat`.
@@ -234,10 +234,9 @@ export class BlenderSkinDirector {
234
234
  return clip.clipStart + this.#action.time * clip.fps;
235
235
  }
236
236
 
237
- /** Whether anything is actually playable — a Timeline over a file with no
238
- * action still draws its ruler and its range. */
237
+ /** Blender's scene clock exists even when the scene has no action. */
239
238
  get playable(): boolean {
240
- return this.#action !== null;
239
+ return this.#clip !== null && this.#transport !== null;
241
240
  }
242
241
 
243
242
  // ------------------------------------------------------------ the binding
@@ -289,20 +288,27 @@ export class BlenderSkinDirector {
289
288
  this.#engineCalls++;
290
289
  const clip = await read.clip();
291
290
  const previous = this.#clip;
291
+ const rangeChanged = clip?.frameStart !== previous?.frameStart ||
292
+ clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
292
293
  const movedAction =
293
294
  clip?.action !== previous?.action ||
294
295
  clip?.tracks.length !== previous?.tracks.length ||
295
296
  clip?.clipStart !== previous?.clipStart ||
296
297
  clip?.clipEnd !== previous?.clipEnd;
297
298
  this.#clip = clip ?? null;
299
+ if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
298
300
  if (clip?.reason) warnings.push(clip.reason);
299
301
  if (clip && (movedAction || changed)) this.#loadClip(presentation, clip, warnings);
302
+ // The subject can attach before the asynchronous scene read arrives.
303
+ // Refresh its clock range through the transport's own subject door.
304
+ const activeSubject = this.#transport?.snapshot().activeSubject;
305
+ if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
300
306
  // BLENDER'S OWN FRAME RE-SYNCS THE PLAYHEAD. An agent that set
301
307
  // `scene.frame_current` in bpy has said where it wants to be, and a
302
308
  // present is how we hear about it; while the transport is PLAYING it would
303
309
  // be the Timeline arguing with itself, so it is honoured only at rest.
304
310
  const playing = this.#transport?.snapshot().playbackState === 'playing';
305
- if (clip && !playing && previous?.frameCurrent !== clip.frameCurrent) {
311
+ if (clip && !playing && (rangeChanged || previous?.frameCurrent !== clip.frameCurrent)) {
306
312
  this.#seek(clip.frameCurrent);
307
313
  // THE BOOKMARK READ, once per bind: the file says where it was left, and
308
314
  // the transport is what everything else now asks.
@@ -482,14 +488,17 @@ export class BlenderSkinDirector {
482
488
 
483
489
  #seek(frame: number): void {
484
490
  const clip = this.#clip;
485
- if (!clip || !this.#mixer || clip.clipStart === undefined || clip.clipEnd === undefined) return;
486
- const clamped = Math.min(clip.clipEnd, Math.max(clip.clipStart, frame));
491
+ if (!clip) return;
492
+ const clamped = Math.min(clip.frameEnd, Math.max(clip.frameStart, frame));
487
493
  this.#seeked = clamped;
494
+ // An action supplies a pose at this scene time; it does not own whether
495
+ // the scene clock can move. Empty/static scenes still play and scrub.
496
+ if (!this.#mixer || clip.clipStart === undefined || clip.clipEnd === undefined) return;
488
497
  // The TIME is nudged inside the clip so the last frame evaluates as the
489
498
  // last frame rather than wrapping to the first; the number REPORTED is the
490
499
  // one asked for (see `frame`).
491
500
  const duration = (clip.clipEnd - clip.clipStart) / clip.fps;
492
- this.#mixer.setTime(Math.min(duration - 1e-4, (clamped - clip.clipStart) / clip.fps));
501
+ this.#mixer.setTime(Math.max(0, Math.min(duration - 1e-4, (clamped - clip.clipStart) / clip.fps)));
493
502
  this.#refresh();
494
503
  }
495
504
 
@@ -601,6 +610,8 @@ export class BlenderSkinDirector {
601
610
  this.#mixer?.stopAllAction();
602
611
  this.#mixer = null;
603
612
  this.#action = null;
613
+ this.#clip = null;
614
+ this.#seeked = null;
604
615
  for (const rig of this.#rigs.values()) {
605
616
  rig.skeleton.dispose();
606
617
  rig.boneRoot.removeFromParent();
@@ -112,22 +112,10 @@ const REFUSALS = {
112
112
  'Selecting a keyframe writes `Keyframe.select_control_point`. The view draws the flags the data carries — a selected key takes `.common.anim.keyframe_selected` — and changes none of them.',
113
113
  } as const;
114
114
 
115
- /**
116
- * WHY A TRANSPORT GESTURE DOES NOTHING OVER A FILE WITH NO ACTION, in one
117
- * sentence, with ONE author: the `play` verb throws it and the header's
118
- * controls wear it as their title while they are disabled.
119
- *
120
- * It exists because the two halves disagreed. `volter.timeline.play` refused by
121
- * name while the BUTTON called `transport()?.play()` straight through — the
122
- * clock went to `playing`, both glyphs flipped to Pause, and the playhead
123
- * stayed on frame 1 forever, because `#seek`/the play tick return early with
124
- * no mixer to move (`blender-runtime-skin.ts`). Measured on U8's walk 3
125
- * (2026-09-20) over a `--template models` scaffold: `volter.timeline.state`
126
- * answered `action: null` with that warning while the header showed a running
127
- * transport. A control whose success can be invisible must say so.
128
- */
115
+ /** The scene clock is available with or without an action. Only a missing
116
+ * scene/transport prevents timeline navigation. */
129
117
  const NOT_PLAYABLE =
130
- 'Nothing here is playable: no object in the scene carries an action this view could bind a mixer to.';
118
+ 'No scene transport is attached yet; open the model document first.';
131
119
 
132
120
  /**
133
121
  * THE TRANSPORT THIS LOOK DRIVES — the Model document's, attached by
@@ -235,7 +223,7 @@ function report(): unknown {
235
223
  * stage's own render loop draws the next frame from them. Blender is not
236
224
  * called — `state().engineCalls` is how that is checked.
237
225
  *
238
- * IT REFUSES OVER A FILE WITH NO ACTION, for the reason {@link NOT_PLAYABLE}
226
+ * IT REFUSES WITHOUT A SCENE TRANSPORT, for the reason {@link NOT_PLAYABLE}
239
227
  * states and with that one sentence: `#seek` returns at its first line with
240
228
  * no mixer (`blender-runtime-skin.ts`), so the seek was a no-op and
241
229
  * `report()` then answered `frame: 1` — the playhead's honest position and a
@@ -886,11 +874,8 @@ function TimelineHeader() {
886
874
  useSyncExternalStore(subscribeTimelineView, timelineViewVersion, timelineViewVersion);
887
875
  const play = blenderSkin.state();
888
876
  const unit = TIMELINE_CHROME.unit;
889
- // THE TRANSPORT IS ONLY AS TRUE AS THE FILE. With no action there is no
890
- // mixer, so every seek and the play tick return early; a control that still
891
- // accepted the click reported a state the picture never took (see
892
- // NOT_PLAYABLE). Disabled, wearing the reason as its title, is what the
893
- // `play` verb already answers.
877
+ // Static scenes have a timeline too. Disable only while its scene/transport
878
+ // is unavailable, using the same condition as the command door.
894
879
  const playable = blenderSkin.playable;
895
880
  const button = (key: string, glyph: ReactNode, onClick: () => void, label: string) => (
896
881
  <button
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.72",
5
+ "version": "0.1.74",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -70,14 +70,14 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "1.30.0",
73
- "@volter/blender-engine": "0.1.70",
74
- "@volter/editor-threejs": "0.5.132",
73
+ "@volter/blender-engine": "0.1.72",
74
+ "@volter/editor-threejs": "0.5.134",
75
75
  "zod": "^4.3.6"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/editor-live": "0.5.132",
79
- "@volter/editor-project": "0.5.132",
80
- "@volter/editor-sdk": "0.5.132",
78
+ "@volter/editor-live": "0.5.134",
79
+ "@volter/editor-project": "0.5.134",
80
+ "@volter/editor-sdk": "0.5.134",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0",
83
83
  "three": "^0.180.0",