@volter/editor-blender 0.1.149 → 0.1.151
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.
- package/LICENSE +18 -9
- package/README.md +5 -5
- package/contributions/blender-animation-editors.tsx +17 -69
- package/contributions/blender-display-settings.ts +36 -0
- package/contributions/blender-export-gltf.ts +228 -0
- package/contributions/blender-export.verbs.ts +39 -0
- package/contributions/blender-gltf-export.verbs.ts +312 -0
- package/contributions/blender-header-menus.tsx +37 -133
- package/contributions/blender-icon-trace.mjs +8 -98
- package/contributions/blender-icons.source.mjs +96 -860
- package/contributions/blender-mixer-pose.ts +369 -0
- package/contributions/blender-model-opening.tsx +3 -7
- package/contributions/blender-node-editor.document.tsx +28 -180
- package/contributions/blender-node-geometry.ts +44 -230
- package/contributions/blender-node-panels.source.mjs +7 -121
- package/contributions/blender-outliner-authoring.ts +61 -415
- package/contributions/blender-outliner-model.ts +15 -112
- package/contributions/blender-play-area.tsx +100 -0
- package/contributions/blender-playback-movie.ts +409 -0
- package/contributions/blender-pose.ts +18 -368
- package/contributions/blender-properties-context.ts +1 -1
- package/contributions/blender-properties-model.ts +15 -90
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +15 -147
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +217 -339
- package/contributions/blender-scene-mixer.ts +51 -0
- package/contributions/blender-screenshot.service.ts +28 -0
- package/contributions/blender-timeline-geometry.ts +16 -136
- package/contributions/blender-timeline.document.tsx +142 -332
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +43 -23
- package/contributions/blender.environment.ts +2 -2
- package/contributions/blender.icons.json +0 -9
- package/contributions/blender.keymap.ts +9 -51
- package/contributions/blender.node-panels.json +4 -13
- package/contributions/blender.page.ts +63 -0
- package/contributions/blender.status.tsx +9 -145
- package/contributions/blender.style.ts +25 -152
- package/contributions/blender.verbs.ts +52 -0
- package/contributions/model.layout.ts +2 -34
- package/contributions/models.finder.ts +12 -32
- package/contributions/properties-bone-constraints.inspector.tsx +1 -16
- package/contributions/properties-bone.inspector.tsx +2 -31
- package/contributions/properties-collection.inspector.tsx +1 -34
- package/contributions/properties-constraints.inspector.tsx +1 -21
- package/contributions/properties-data.inspector.tsx +2 -39
- package/contributions/properties-material.inspector.tsx +2 -33
- package/contributions/properties-modifiers.inspector.tsx +2 -30
- package/contributions/properties-object.inspector.tsx +2 -44
- package/contributions/properties-output.inspector.tsx +1 -27
- package/contributions/properties-particles.inspector.tsx +2 -46
- package/contributions/properties-physics.inspector.tsx +2 -41
- package/contributions/properties-render.inspector.tsx +1 -45
- package/contributions/properties-scene.inspector.tsx +1 -35
- package/contributions/properties-texture.inspector.tsx +1 -49
- package/contributions/properties-view-layer.inspector.tsx +1 -29
- package/contributions/properties-world.inspector.tsx +2 -39
- package/contributions/shading.layout.ts +3 -78
- package/contributions/uv-editing.layout.ts +3 -70
- package/dist-node/serving.mjs +13 -30
- package/host/blender-runtime-host.ts +541 -372
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +49 -93
- package/package.json +1209 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -43
- package/serving/blender-wasm-artifact.ts +29 -166
- package/serving/index.ts +1 -1
- package/src/api.ts +9 -0
- package/src/layouts.tsx +1 -17
- package/src/looks.ts +1 -12
- package/src/model-document-saving.ts +25 -0
- package/src/model-play-extension.ts +173 -0
- package/src/node-view-state.ts +3 -25
- package/src/play-mode.ts +64 -38
- package/src/presentation.ts +6 -18
- package/src/regions.ts +3 -43
- package/src/start-document.ts +15 -0
- package/src/timeline-view-state.ts +4 -39
- package/src/uv-view-state.ts +4 -20
- package/themes/blender.json +299 -0
- package/view/blender-view-routes.ts +1 -1
- package/contributions/blender-game-panel.tsx +0 -809
- package/contributions/blender-palette.source.mjs +0 -350
- package/contributions/blender-play-skin.ts +0 -508
- package/contributions/blender-screenshot.menu.ts +0 -32
- package/contributions/blender.command.ts +0 -109
- package/contributions/blender.palette.json +0 -102
- package/src/play-live.ts +0 -27
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SCENE'S SOUND, MIXED IN THE PAGE (docs/adr/movie-sound-and-react-frames.md, decision 2): this
|
|
3
|
+
* Blender build has no audio (`aud` and `sound.mixdown` are unavailable), so `render-movie` mixes what `session.py`'s `sound-scene` describes in an
|
|
4
|
+
* OfflineAudioContext, 48 kHz stereo, from the movie's first frame at 0 s:
|
|
5
|
+
*
|
|
6
|
+
* - a sound strip plays its file from where the strip is cut, at its speed, through its volume per
|
|
7
|
+
* frame (a fade is keys on volume) and, when its sound is mono, its pan per frame;
|
|
8
|
+
* - a speaker plays its file from each of its NLA sound strips' start frames, through its volume per
|
|
9
|
+
* frame, from its place and facing per frame, heard from the scene camera's (the listener's);
|
|
10
|
+
* - the scene's own volume scales the mix, and a muted strip, channel, speaker or scene is silent.
|
|
11
|
+
*
|
|
12
|
+
* What differs from Blender's own mix is listed in the ADR and in SCENE-ANIMATION.md.
|
|
13
|
+
*/
|
|
14
|
+
import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
|
|
15
|
+
import type { BlenderClipColumn, BlenderSoundFile, BlenderSoundScene } from '@volter/blender-engine/browser/rna';
|
|
16
|
+
|
|
17
|
+
const SCENE_SOUND_RATE = 48_000;
|
|
18
|
+
|
|
19
|
+
/** A per-frame column as `count` values of `stride` floats (a constant column repeated). */
|
|
20
|
+
function column(source: BlenderClipColumn, count: number, stride: number): Float32Array {
|
|
21
|
+
const bytes = bytesFromBase64(source.base64);
|
|
22
|
+
const values = new Float32Array(bytes.buffer);
|
|
23
|
+
if (source.count >= count) return values;
|
|
24
|
+
const out = new Float32Array(count * stride);
|
|
25
|
+
for (let i = 0; i < count; i++) out.set(values.subarray(0, stride), i * stride);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `param` follows `values` (one per frame), ramped linearly between frames from 0 s. */
|
|
30
|
+
function follow(param: AudioParam, values: Float32Array, fps: number, stride = 1, offset = 0, map: (v: number) => number = (v) => v): void {
|
|
31
|
+
const count = values.length / stride;
|
|
32
|
+
param.setValueAtTime(map(values[offset]!), 0);
|
|
33
|
+
for (let i = 1; i < count; i++) param.linearRampToValueAtTime(map(values[i * stride + offset]!), i / fps);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DISTANCE_MODELS: Readonly<Record<string, DistanceModelType>> = {
|
|
37
|
+
INVERSE: 'inverse', INVERSE_CLAMPED: 'inverse', LINEAR: 'linear', LINEAR_CLAMPED: 'linear', EXPONENT: 'exponential', EXPONENT_CLAMPED: 'exponential',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The movie's sound as one 48 kHz stereo buffer's planes, or null when the scene has none to play
|
|
42
|
+
* (no unmuted strip or speaker with a file). `read` gives a file's bytes.
|
|
43
|
+
*/
|
|
44
|
+
export async function mixSceneSound(
|
|
45
|
+
scene: BlenderSoundScene,
|
|
46
|
+
read: (file: BlenderSoundFile) => Promise<Uint8Array>,
|
|
47
|
+
): Promise<{ readonly planes: Float32Array[]; readonly sampleRate: number; readonly strips: number; readonly speakers: number } | null> {
|
|
48
|
+
if (scene.muted && scene.speakers.length === 0) return null;
|
|
49
|
+
const strips = scene.muted ? [] : scene.strips.filter((strip) => !strip.muted && strip.file !== null);
|
|
50
|
+
const speakers = scene.speakers.filter((speaker) => !speaker.muted && speaker.file !== null && speaker.starts.length > 0);
|
|
51
|
+
if (strips.length === 0 && speakers.length === 0) return null;
|
|
52
|
+
|
|
53
|
+
const { fps, first, count } = scene;
|
|
54
|
+
const seconds = count / fps;
|
|
55
|
+
const context = new OfflineAudioContext({ numberOfChannels: 2, length: Math.ceil(seconds * SCENE_SOUND_RATE), sampleRate: SCENE_SOUND_RATE });
|
|
56
|
+
const master = context.createGain();
|
|
57
|
+
master.gain.value = scene.volume;
|
|
58
|
+
master.connect(context.destination);
|
|
59
|
+
|
|
60
|
+
const decoded = new Map<string, Promise<AudioBuffer>>();
|
|
61
|
+
const decode = (file: BlenderSoundFile): Promise<AudioBuffer> => {
|
|
62
|
+
const key = 'path' in file ? file.path : file.packed;
|
|
63
|
+
let buffer = decoded.get(key);
|
|
64
|
+
if (!buffer) {
|
|
65
|
+
buffer = read(file).then((bytes) => context.decodeAudioData(bytes.slice().buffer as ArrayBuffer)).catch((thrown: unknown) => {
|
|
66
|
+
throw new Error(`The sound ${key} could not be read or decoded in this browser: ${thrown instanceof Error ? thrown.message : String(thrown)}`);
|
|
67
|
+
});
|
|
68
|
+
decoded.set(key, buffer);
|
|
69
|
+
}
|
|
70
|
+
return buffer;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (const strip of strips) {
|
|
74
|
+
const from = Math.max(strip.finalStart, first);
|
|
75
|
+
const to = Math.min(strip.finalEnd, first + count);
|
|
76
|
+
if (to <= from) continue;
|
|
77
|
+
const buffer = await decode(strip.file!);
|
|
78
|
+
const source = context.createBufferSource();
|
|
79
|
+
source.buffer = buffer;
|
|
80
|
+
source.playbackRate.value = strip.speed;
|
|
81
|
+
const gain = context.createGain();
|
|
82
|
+
follow(gain.gain, column(strip.volume, count, 1), fps);
|
|
83
|
+
source.connect(gain);
|
|
84
|
+
let out: AudioNode = gain;
|
|
85
|
+
if (strip.mono || buffer.numberOfChannels === 1) {
|
|
86
|
+
// Blender pans only a mono sound; a mono strip of a stereo file is mixed down first
|
|
87
|
+
gain.channelCount = 1;
|
|
88
|
+
gain.channelCountMode = 'explicit';
|
|
89
|
+
gain.channelInterpretation = 'speakers';
|
|
90
|
+
const pan = context.createStereoPanner();
|
|
91
|
+
follow(pan.pan, column(strip.pan, count, 1), fps, 1, 0, (v) => Math.max(-1, Math.min(1, v)));
|
|
92
|
+
gain.connect(pan);
|
|
93
|
+
out = pan;
|
|
94
|
+
}
|
|
95
|
+
out.connect(master);
|
|
96
|
+
source.start((from - first) / fps, ((from - strip.start) / fps) * strip.speed, ((to - from) / fps) * strip.speed);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (speakers.length > 0 && scene.listener) {
|
|
100
|
+
const listener = context.listener;
|
|
101
|
+
const position = column(scene.listener.position, count, 3);
|
|
102
|
+
const forward = column(scene.listener.forward, count, 3);
|
|
103
|
+
const up = column(scene.listener.up, count, 3);
|
|
104
|
+
follow(listener.positionX, position, fps, 3, 0); follow(listener.positionY, position, fps, 3, 1); follow(listener.positionZ, position, fps, 3, 2);
|
|
105
|
+
follow(listener.forwardX, forward, fps, 3, 0); follow(listener.forwardY, forward, fps, 3, 1); follow(listener.forwardZ, forward, fps, 3, 2);
|
|
106
|
+
follow(listener.upX, up, fps, 3, 0); follow(listener.upY, up, fps, 3, 1); follow(listener.upZ, up, fps, 3, 2);
|
|
107
|
+
}
|
|
108
|
+
for (const speaker of speakers) {
|
|
109
|
+
const buffer = await decode(speaker.file!);
|
|
110
|
+
const gain = context.createGain();
|
|
111
|
+
follow(gain.gain, column(speaker.volume, count, 1), fps, 1, 0, (v) => Math.max(speaker.volumeMin, Math.min(speaker.volumeMax, v)));
|
|
112
|
+
const panner = context.createPanner();
|
|
113
|
+
panner.panningModel = 'equalpower';
|
|
114
|
+
panner.distanceModel = DISTANCE_MODELS[scene.distanceModel] ?? 'inverse';
|
|
115
|
+
panner.refDistance = speaker.distanceReference;
|
|
116
|
+
panner.maxDistance = speaker.distanceMax;
|
|
117
|
+
panner.rolloffFactor = scene.distanceModel === 'NONE' ? 0 : speaker.attenuation;
|
|
118
|
+
panner.coneInnerAngle = (speaker.coneInner * 180) / Math.PI;
|
|
119
|
+
panner.coneOuterAngle = (speaker.coneOuter * 180) / Math.PI;
|
|
120
|
+
panner.coneOuterGain = speaker.coneOuterVolume;
|
|
121
|
+
const position = column(speaker.position, count, 3);
|
|
122
|
+
const facing = column(speaker.facing, count, 3);
|
|
123
|
+
follow(panner.positionX, position, fps, 3, 0); follow(panner.positionY, position, fps, 3, 1); follow(panner.positionZ, position, fps, 3, 2);
|
|
124
|
+
follow(panner.orientationX, facing, fps, 3, 0); follow(panner.orientationY, facing, fps, 3, 1); follow(panner.orientationZ, facing, fps, 3, 2);
|
|
125
|
+
gain.connect(panner).connect(master);
|
|
126
|
+
for (const start of speaker.starts) {
|
|
127
|
+
const end = start + (buffer.duration * fps);
|
|
128
|
+
if (end <= first || start >= first + count) continue;
|
|
129
|
+
const source = context.createBufferSource();
|
|
130
|
+
source.buffer = buffer;
|
|
131
|
+
source.connect(gain);
|
|
132
|
+
source.start(Math.max(0, (start - first) / fps), Math.max(0, (first - start) / fps));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const mixed = await context.startRendering();
|
|
137
|
+
return {
|
|
138
|
+
planes: [mixed.getChannelData(0), mixed.getChannelData(1)],
|
|
139
|
+
sampleRate: SCENE_SOUND_RATE,
|
|
140
|
+
strips: strips.length,
|
|
141
|
+
speakers: speakers.length,
|
|
142
|
+
};
|
|
143
|
+
}
|
package/node/blender-mcp.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* No modeling happens in this process. `execute_blender_code`,
|
|
7
7
|
* `get_scene_info`, `get_object_info` and `get_viewport_screenshot` become
|
|
8
8
|
* `blender-*` control commands answered by the tab's worker
|
|
9
|
-
* (`packages/editor-blender/contributions/blender.
|
|
10
|
-
*
|
|
11
|
-
* photographing its own Model document. The
|
|
12
|
-
*
|
|
9
|
+
* (`packages/editor-blender/contributions/blender.verbs.ts`, the engine's own
|
|
10
|
+
* Code-OSS commands); the screenshot is the tab
|
|
11
|
+
* photographing its own Model document. The remaining tools return static
|
|
12
|
+
* status or disabled-integration responses.
|
|
13
13
|
*/
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
15
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
ListPromptsRequestSchema,
|
|
23
23
|
ListToolsRequestSchema,
|
|
24
24
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
25
|
-
import { EditorClient
|
|
25
|
+
import { EditorClient } from '@volter/live/client';
|
|
26
|
+
import type { EditorView } from '@volter/sdk';
|
|
26
27
|
import blenderBundle from '@volter/blender-engine/wasm/BUNDLE.json';
|
|
27
28
|
import { projectOutputRootOf } from '@volter/sdk/project/output-roots';
|
|
28
29
|
import blenderDefaults from './blender-mcp-defaults.json';
|
|
@@ -110,52 +111,21 @@ function projectRelative(project: string, absolute: string): string | null {
|
|
|
110
111
|
* (`C:\Users\me\p`) at its URI path (`/C:/Users/me/p`) — `projectMount` in
|
|
111
112
|
* `@volter/blender-engine/browser/worker.ts`, which this mirrors because that module is the
|
|
112
113
|
* worker's and cannot load here. Every path crossing the transport goes through these two:
|
|
113
|
-
* the engine's spelling on the wire, the host's on disk.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
114
|
+
* the engine's spelling on the wire, the host's on disk. Sending the host's spelling would have
|
|
115
|
+
* the worker list nothing at a path it does not have, and on Windows no script output would
|
|
116
|
+
* reach disk while every call reported success.
|
|
116
117
|
*/
|
|
117
|
-
|
|
118
|
+
function engineSpelling(host: string): string {
|
|
118
119
|
return /^[A-Za-z]:[\\/]/.test(host) ? `/${host.replaceAll('\\', '/')}` : host;
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
/** {@link engineSpelling} backwards: `/C:/Users/me/p/x.glb` → `C:\Users\me\p\x.glb` on
|
|
122
123
|
* Windows; a POSIX host's paths are the engine's own. */
|
|
123
|
-
|
|
124
|
-
return platform === 'win32' && /^\/[A-Za-z]:\//.test(engine) ? engine.slice(1).replaceAll('/', '\\') : engine;
|
|
124
|
+
function hostSpelling(engine: string): string {
|
|
125
|
+
return process.platform === 'win32' && /^\/[A-Za-z]:\//.test(engine) ? engine.slice(1).replaceAll('/', '\\') : engine;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
/**
|
|
128
|
-
* The worker's filesystem is the only place a script's outputs exist; this
|
|
129
|
-
* mirrors them to disk. Roots: the project (a script's `public/models/x.glb`
|
|
130
|
-
* lands in the project) plus any `VOLTER_BLENDER_MIRROR_ROOTS` (absolute paths separated by
|
|
131
|
-
* the platform's own `path.delimiter` — `:` on POSIX, `;` on Windows, where a drive letter's
|
|
132
|
-
* colon is part of the path — the replay harness names its run directory). Only files
|
|
133
|
-
* whose size or mtime changed since the last mirror are read back, and only
|
|
134
|
-
* files the SESSION owns are listed at all — a project file it merely read
|
|
135
|
-
* belongs to the host and is never written back over it.
|
|
136
|
-
*
|
|
137
|
-
* THIS IS THE DOOR THAT RECORDS, because it is the door a session's bytes
|
|
138
|
-
* enter the project through. MEASURED 2026-09-18: a script's
|
|
139
|
-
* `export_scene.gltf` to `<project>/public/models/lantern.glb` landed here and
|
|
140
|
-
* `.volter/provenance.json` was never created — the one state
|
|
141
|
-
* `scripts/validate-project-provenance.mjs` calls fatal, and the opposite of
|
|
142
|
-
* the doctrine that a generated artifact enters `public/` through a door that
|
|
143
|
-
* records it atomically. So a file this lands under `public/` is POSTed to the
|
|
144
|
-
* session's `/__editor/blender-output`, which commits the bytes and the ledger
|
|
145
|
-
* rewrite as ONE rollback-safe transaction through the same writer the
|
|
146
|
-
* editor's own bake and acceptance tools use; the record's kind is
|
|
147
|
-
* `project.session.write` and it names the session (see
|
|
148
|
-
* `ProjectProvenanceSessionSchema`).
|
|
149
|
-
*
|
|
150
|
-
* The session process owns the writer and the project's ledger. This
|
|
151
|
-
* transport uses that same session boundary as the other product commands;
|
|
152
|
-
* it never creates a second ledger writer in the MCP process.
|
|
153
|
-
*
|
|
154
|
-
* Everything else the session owns — `.volter/tmp/*.png`, a `.blend`, a replay
|
|
155
|
-
* harness's run directory outside the project — is mirrored with a plain write
|
|
156
|
-
* and deliberately NOT recorded: the ledger is about what the project SHIPS,
|
|
157
|
-
* and `public/` is what ships.
|
|
158
|
-
*/
|
|
128
|
+
/** Mirror session-owned output files whose size or mtime changed to the project or VOLTER_BLENDER_MIRROR_ROOTS. Files under public/ go through /__editor/blender-output, which commits bytes and provenance atomically under the session writer. Other outputs use plain writes. Never mirror a host input merely read by Blender. */
|
|
159
129
|
class Mirror {
|
|
160
130
|
readonly #seen = new Map<string, string>();
|
|
161
131
|
constructor(
|
|
@@ -165,13 +135,12 @@ class Mirror {
|
|
|
165
135
|
private readonly source: string,
|
|
166
136
|
) {}
|
|
167
137
|
|
|
168
|
-
async sync(tab: TabSession, callId: string
|
|
169
|
-
let written = 0;
|
|
138
|
+
async sync(tab: TabSession, callId: string): Promise<void> {
|
|
170
139
|
for (const root of this.roots) {
|
|
171
|
-
const listing = await tab.
|
|
140
|
+
const listing = await tab.commandHeld<{
|
|
172
141
|
entries: FileEntry[];
|
|
173
|
-
session
|
|
174
|
-
revision
|
|
142
|
+
session: string;
|
|
143
|
+
revision: number;
|
|
175
144
|
document?: string;
|
|
176
145
|
}>('blender-list-files', { path: engineSpelling(root) });
|
|
177
146
|
for (const entry of listing.entries) {
|
|
@@ -185,20 +154,10 @@ class Mirror {
|
|
|
185
154
|
projectPath !== null && projectOutputRootOf(projectPath) === 'public'
|
|
186
155
|
? projectPath
|
|
187
156
|
: null;
|
|
188
|
-
// ANTI-SHIM: an unrecordable shipped file is REFUSED, not written
|
|
189
|
-
// anyway. Landing it unrecorded is the defect this door exists to
|
|
190
|
-
// close, and inventing a session for it would be worse than both.
|
|
191
|
-
if (shipped !== null && (listing.session === undefined || listing.revision === undefined))
|
|
192
|
-
throw new Error(
|
|
193
|
-
`The Blender session wrote ${shipped} but has not named itself — it has presented no ` +
|
|
194
|
-
'frame, so there is no session identity or revision to record the file under, and ' +
|
|
195
|
-
'an unrecorded binary under public/ is what `npm run validate-provenance` calls ' +
|
|
196
|
-
'fatal. Nothing was written for it.',
|
|
197
|
-
);
|
|
198
157
|
// WE mint the transfer id and WE choose where the bytes land; the page
|
|
199
158
|
// only hands over a body. See `/__editor/blender-file`.
|
|
200
159
|
const transferId = randomUUID();
|
|
201
|
-
await tab.
|
|
160
|
+
await tab.commandHeld<{ bytes: number }>('blender-read-file', { path: entry.path, transferId });
|
|
202
161
|
const spooled = await fetch(`${await tab.origin()}/__editor/blender-file?id=${transferId}`);
|
|
203
162
|
if (!spooled.ok) throw new Error(`Transfer of ${entry.path} failed: ${spooled.status}`);
|
|
204
163
|
const bytes = Buffer.from(await spooled.arrayBuffer());
|
|
@@ -219,10 +178,10 @@ class Mirror {
|
|
|
219
178
|
} else {
|
|
220
179
|
const query = new URLSearchParams({
|
|
221
180
|
path: shipped,
|
|
222
|
-
session: listing.session
|
|
223
|
-
revision: String(listing.revision
|
|
181
|
+
session: listing.session,
|
|
182
|
+
revision: String(listing.revision),
|
|
224
183
|
source: this.source,
|
|
225
|
-
|
|
184
|
+
callId,
|
|
226
185
|
});
|
|
227
186
|
// WHAT THE BYTES WERE MADE FROM, when the session holds a document:
|
|
228
187
|
// the `.blend` this export came out of. Passed as a repeatable
|
|
@@ -242,10 +201,8 @@ class Mirror {
|
|
|
242
201
|
}
|
|
243
202
|
}
|
|
244
203
|
this.#seen.set(entry.path, stamp);
|
|
245
|
-
written += 1;
|
|
246
204
|
}
|
|
247
205
|
}
|
|
248
|
-
return written;
|
|
249
206
|
}
|
|
250
207
|
}
|
|
251
208
|
|
|
@@ -253,7 +210,6 @@ class TabSession {
|
|
|
253
210
|
#client: EditorClient | null = null;
|
|
254
211
|
#connecting: Promise<EditorClient> | null = null;
|
|
255
212
|
#origin: string | null = null;
|
|
256
|
-
#port: number | null = null;
|
|
257
213
|
/** A replay starts with a fresh model once; ordinary agent sessions persist. */
|
|
258
214
|
#freshSessionPending = Boolean(process.env['VOLTER_BLENDER_FRESH_SESSION']);
|
|
259
215
|
constructor(
|
|
@@ -268,7 +224,6 @@ class TabSession {
|
|
|
268
224
|
await this.ensureEditor();
|
|
269
225
|
const { resolveSession } = await import('@volter/live');
|
|
270
226
|
const session = await resolveSession(this.project);
|
|
271
|
-
this.#port = session.port;
|
|
272
227
|
this.#origin = `http://127.0.0.1:${session.port}`;
|
|
273
228
|
this.#client = new EditorClient({ url: this.#origin });
|
|
274
229
|
return this.#client;
|
|
@@ -285,44 +240,36 @@ class TabSession {
|
|
|
285
240
|
return this.#origin as string;
|
|
286
241
|
}
|
|
287
242
|
|
|
288
|
-
/** The machine-local port this project's session answers on — the address a
|
|
289
|
-
* provenance record names so a reader can get back to the tab. */
|
|
290
|
-
async port(): Promise<number> {
|
|
291
|
-
await this.client();
|
|
292
|
-
return this.#port as number;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
243
|
/**
|
|
296
244
|
* The Model document open and the session started at this project's path.
|
|
297
245
|
*
|
|
298
246
|
* Asked every call, never remembered: the session lives in the editor tab,
|
|
299
247
|
* so an editor restart ends it while this process keeps running.
|
|
300
|
-
* The editor owns opening the Model document and idempotent startup.
|
|
301
|
-
* separate status query would put a five-second check before that startup
|
|
302
|
-
* request and introduce a race with an editor restart.
|
|
248
|
+
* The editor owns opening the Model document and idempotent startup on each call.
|
|
303
249
|
*/
|
|
304
250
|
async started(): Promise<EditorClient> {
|
|
305
251
|
const client = await this.client();
|
|
306
252
|
const fresh = this.#freshSessionPending;
|
|
307
253
|
this.#freshSessionPending = false;
|
|
308
|
-
await client.
|
|
254
|
+
await client.contributedVerb('blender-start', { project: this.project, ...(fresh ? { fresh } : {}) });
|
|
309
255
|
return client;
|
|
310
256
|
}
|
|
311
257
|
|
|
312
258
|
async command<T>(type: string, fields: Record<string, unknown> = {}): Promise<T> {
|
|
313
259
|
const client = await this.started();
|
|
314
|
-
return client.
|
|
260
|
+
return client.contributedVerb<T & object>(type, fields);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Read outputs through the session the call used, without another start. During Save As the tab can briefly lag Blender’s file; output reads need no document binding. */
|
|
264
|
+
async commandHeld<T>(type: string, fields: Record<string, unknown>): Promise<T> {
|
|
265
|
+
const client = await this.client();
|
|
266
|
+
return client.contributedVerb<T & object>(type, fields);
|
|
315
267
|
}
|
|
316
268
|
}
|
|
317
269
|
|
|
318
270
|
const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] });
|
|
319
271
|
|
|
320
|
-
/**
|
|
321
|
-
* is REFUSED by name rather than dropped: dropping it was how a Windows root (`C:\…`, split on
|
|
322
|
-
* its own colon) silently mirrored nothing. Blender's filesystem has a mount for a POSIX root
|
|
323
|
-
* (`/runs/x`) and a drive root (`C:\runs\x`, as `/C:/runs/x`) only, so a relative path, a UNC
|
|
324
|
-
* share (`\\server\share`) and a drive-less rooted path (`\runs\x`) — all of which Node calls
|
|
325
|
-
* absolute or nearly — are refused too (#147 review). */
|
|
272
|
+
/** Split VOLTER_BLENDER_MIRROR_ROOTS by the platform delimiter. Accept only mountable POSIX or drive roots; refuse relative, UNC and drive-less rooted paths by name. */
|
|
326
273
|
function mirrorRoots(raw: string | undefined): string[] {
|
|
327
274
|
const roots = (raw ?? '').split(delimiter).filter((root) => root !== '');
|
|
328
275
|
const mountable = (root: string) => /^[A-Za-z]:[\\/]/.test(root) || (root.startsWith('/') && !root.startsWith('//'));
|
|
@@ -342,17 +289,14 @@ export async function serveBlenderMcp(
|
|
|
342
289
|
command: string,
|
|
343
290
|
): Promise<void> {
|
|
344
291
|
const runtimeIdentity = `Blender ${blenderBundle.blender} (source ${blenderBundle.source}), compiled to WebAssembly and running headless in the editor tab; three.js takes its photographs. Factory bpy.context.scene.render.engine: '${blenderBundle.factoryEngine}'. Documents are .blend. save_as_mainfile records the actual saved path in bpy.data.filepath; open_mainfile reopens it.`;
|
|
345
|
-
//
|
|
346
|
-
// it goes rendered one, wrote it into the project and ran the OS `open` on it: Preview started
|
|
347
|
-
// outside the editor. The editor already shows a picture as one of its documents.
|
|
292
|
+
// Photographs remain visible in the editor's own image documents.
|
|
348
293
|
const photographs = `A photograph is shown to the person in the editor, never in another app: get_viewport_screenshot returns one, and an image file written inside the project opens as an editor document with \`npx ${command} eval 'editor.openAsset("<project-relative path>")'\`. Do not run the OS \`open\` on it.`;
|
|
349
294
|
// Blender's own spelling of the root: on Windows the project's `C:\…` is `/C:/…` inside it, and
|
|
350
295
|
// a script told the host spelling writes to a path that is not the project's.
|
|
351
296
|
const mounted = engineSpelling(project);
|
|
352
297
|
// Files the agent wrote a moment ago are copied in before each call (blender-engine worker,
|
|
353
|
-
// stageProjectFiles), so a project script runs from its file;
|
|
354
|
-
|
|
355
|
-
const filesystem = `Project files are mounted in Blender at the project's actual absolute path: ${mounted}${mounted !== project ? ` (this machine's ${project})` : ''}. Use that root for image loads, saves and exports; /project is not an alias. The project's source files written before a call, including ones written just now, are in place when that call runs (dotfiles and dot-directories, node_modules, dist, coverage and logs are not copied in), so run a project script from its file: exec(open(${JSON.stringify(`${mounted}/src/models/<name>.py`)}).read()).`;
|
|
298
|
+
// stageProjectFiles), so a project script runs from its file; the instructions say so.
|
|
299
|
+
const filesystem = `Project files are mounted in Blender at the project's actual absolute path: ${mounted}${mounted !== project ? ` (this machine's ${project})` : ''}. Use that root for image loads, saves and exports; /project is not an alias. The project's source files written before a call, including ones written just now, are in place when that call runs (dotfiles and dot-directories, node_modules, dist, coverage and logs are not copied in), so run a project script from its file: import runpy; script_globals = runpy.run_path(${JSON.stringify(`${mounted}/src/models/<name>.py`)}, run_name="__main__"). This sets __file__ for sibling asset paths and returns the script's own globals as script_globals.`;
|
|
356
300
|
const instructions = `${runtimeIdentity}\n${filesystem}\n${photographs}`;
|
|
357
301
|
const tools = (blenderTools as ToolShape[]).map((tool) =>
|
|
358
302
|
tool.name === 'execute_blender_code'
|
|
@@ -389,7 +333,7 @@ export async function serveBlenderMcp(
|
|
|
389
333
|
// The transport's own number for THIS call. A file the script writes is
|
|
390
334
|
// recorded under it, so a ledger entry points back at the exact request
|
|
391
335
|
// that produced the bytes.
|
|
392
|
-
const callId =
|
|
336
|
+
const callId = String(extra.requestId);
|
|
393
337
|
if (name in STATIC_TOOLS) return text(STATIC_TOOLS[name]!);
|
|
394
338
|
if (name in defaults) return text(defaults[name]!);
|
|
395
339
|
switch (name) {
|
|
@@ -419,7 +363,20 @@ export async function serveBlenderMcp(
|
|
|
419
363
|
const { result } = await tab.command<{ result: string }>('blender-execute', {
|
|
420
364
|
code: String(args['code'] ?? ''),
|
|
421
365
|
});
|
|
422
|
-
|
|
366
|
+
// The script has run and what it saved is saved: a failure to copy its other files is said BESIDE its
|
|
367
|
+
// answer, preserving the script’s successful result.
|
|
368
|
+
try {
|
|
369
|
+
await mirror.sync(tab, callId);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
return {
|
|
372
|
+
...text(
|
|
373
|
+
`${result}\n\nThe script ran, but the files it wrote did not all reach the project's disk: ` +
|
|
374
|
+
`${error instanceof Error ? error.message : String(error)}. The next execute_blender_code call ` +
|
|
375
|
+
'copies what is still missing.',
|
|
376
|
+
),
|
|
377
|
+
isError: true,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
423
380
|
return text(result);
|
|
424
381
|
}
|
|
425
382
|
case 'get_viewport_screenshot': {
|
|
@@ -429,7 +386,6 @@ export async function serveBlenderMcp(
|
|
|
429
386
|
view?: { size: number; position?: number[]; target?: number[] };
|
|
430
387
|
error?: string;
|
|
431
388
|
}>('blender-screenshot-view', { maxSize });
|
|
432
|
-
if (!answer.document) throw new Error('Screenshot failed: Blender supplied no document address');
|
|
433
389
|
if (!answer.view)
|
|
434
390
|
throw new Error(answer.error ?? 'Screenshot failed: No 3D viewport found');
|
|
435
391
|
const { view } = answer;
|