@volter/editor-blender 0.1.71 → 0.1.73
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
|
-
/**
|
|
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.#
|
|
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
|
|
486
|
-
const clamped = Math.min(clip.
|
|
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
|
-
*
|
|
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
|
-
'
|
|
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
|
|
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
|
-
//
|
|
890
|
-
//
|
|
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.
|
|
5
|
+
"version": "0.1.73",
|
|
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.
|
|
74
|
-
"@volter/editor-threejs": "0.5.
|
|
73
|
+
"@volter/blender-engine": "0.1.71",
|
|
74
|
+
"@volter/editor-threejs": "0.5.133",
|
|
75
75
|
"zod": "^4.3.6"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
|
-
"@volter/editor-live": "0.5.
|
|
79
|
-
"@volter/editor-project": "0.5.
|
|
80
|
-
"@volter/editor-sdk": "0.5.
|
|
78
|
+
"@volter/editor-live": "0.5.133",
|
|
79
|
+
"@volter/editor-project": "0.5.133",
|
|
80
|
+
"@volter/editor-sdk": "0.5.133",
|
|
81
81
|
"react": "^19.0.0",
|
|
82
82
|
"react-dom": "^19.0.0",
|
|
83
83
|
"three": "^0.180.0",
|