@volter/editor-blender 0.1.150 → 0.1.152
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 +26 -56
- package/contributions/blender-node-editor.document.tsx +30 -181
- 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 +99 -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 +59 -91
- package/contributions/blender-properties-tab.tsx +3 -50
- package/contributions/blender-properties-view.tsx +39 -181
- package/contributions/blender-remedy.ts +14 -0
- package/contributions/blender-runtime-skin.ts +103 -99
- package/contributions/blender-runtime.document.tsx +170 -618
- 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 +145 -333
- package/contributions/blender-uv-editor.document.tsx +48 -112
- package/contributions/blender-uv-geometry.ts +14 -179
- package/contributions/blender-version.status.tsx +45 -31
- 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 +64 -0
- package/contributions/blender.status.tsx +8 -150
- 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 +3 -30
- 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 -32
- package/host/blender-runtime-host.ts +795 -412
- package/host/scene-sound.ts +143 -0
- package/node/blender-mcp.ts +52 -95
- package/package.json +1221 -46
- package/producticons/blender-icons.otf +0 -0
- package/producticons/blender-product-icon-theme.json +129 -0
- package/serving/blender-routes.ts +9 -48
- 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/node-view-state.ts +3 -25
- package/src/play-mode.ts +13 -42
- package/src/play-tool.ts +44 -0
- 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,9 @@ 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 { sessionFetch } from '@volter/live/registry-format';
|
|
27
|
+
import type { EditorView } from '@volter/sdk';
|
|
26
28
|
import blenderBundle from '@volter/blender-engine/wasm/BUNDLE.json';
|
|
27
29
|
import { projectOutputRootOf } from '@volter/sdk/project/output-roots';
|
|
28
30
|
import blenderDefaults from './blender-mcp-defaults.json';
|
|
@@ -110,52 +112,21 @@ function projectRelative(project: string, absolute: string): string | null {
|
|
|
110
112
|
* (`C:\Users\me\p`) at its URI path (`/C:/Users/me/p`) — `projectMount` in
|
|
111
113
|
* `@volter/blender-engine/browser/worker.ts`, which this mirrors because that module is the
|
|
112
114
|
* 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
|
-
*
|
|
115
|
+
* the engine's spelling on the wire, the host's on disk. Sending the host's spelling would have
|
|
116
|
+
* the worker list nothing at a path it does not have, and on Windows no script output would
|
|
117
|
+
* reach disk while every call reported success.
|
|
116
118
|
*/
|
|
117
|
-
|
|
119
|
+
function engineSpelling(host: string): string {
|
|
118
120
|
return /^[A-Za-z]:[\\/]/.test(host) ? `/${host.replaceAll('\\', '/')}` : host;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
/** {@link engineSpelling} backwards: `/C:/Users/me/p/x.glb` → `C:\Users\me\p\x.glb` on
|
|
122
124
|
* 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;
|
|
125
|
+
function hostSpelling(engine: string): string {
|
|
126
|
+
return process.platform === 'win32' && /^\/[A-Za-z]:\//.test(engine) ? engine.slice(1).replaceAll('/', '\\') : engine;
|
|
125
127
|
}
|
|
126
128
|
|
|
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
|
-
*/
|
|
129
|
+
/** 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
130
|
class Mirror {
|
|
160
131
|
readonly #seen = new Map<string, string>();
|
|
161
132
|
constructor(
|
|
@@ -165,13 +136,12 @@ class Mirror {
|
|
|
165
136
|
private readonly source: string,
|
|
166
137
|
) {}
|
|
167
138
|
|
|
168
|
-
async sync(tab: TabSession, callId: string
|
|
169
|
-
let written = 0;
|
|
139
|
+
async sync(tab: TabSession, callId: string): Promise<void> {
|
|
170
140
|
for (const root of this.roots) {
|
|
171
|
-
const listing = await tab.
|
|
141
|
+
const listing = await tab.commandHeld<{
|
|
172
142
|
entries: FileEntry[];
|
|
173
|
-
session
|
|
174
|
-
revision
|
|
143
|
+
session: string;
|
|
144
|
+
revision: number;
|
|
175
145
|
document?: string;
|
|
176
146
|
}>('blender-list-files', { path: engineSpelling(root) });
|
|
177
147
|
for (const entry of listing.entries) {
|
|
@@ -185,21 +155,11 @@ class Mirror {
|
|
|
185
155
|
projectPath !== null && projectOutputRootOf(projectPath) === 'public'
|
|
186
156
|
? projectPath
|
|
187
157
|
: 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
158
|
// WE mint the transfer id and WE choose where the bytes land; the page
|
|
199
159
|
// only hands over a body. See `/__editor/blender-file`.
|
|
200
160
|
const transferId = randomUUID();
|
|
201
|
-
await tab.
|
|
202
|
-
const spooled = await
|
|
161
|
+
await tab.commandHeld<{ bytes: number }>('blender-read-file', { path: entry.path, transferId });
|
|
162
|
+
const spooled = await sessionFetch(`${await tab.origin()}/__editor/blender-file?id=${transferId}`);
|
|
203
163
|
if (!spooled.ok) throw new Error(`Transfer of ${entry.path} failed: ${spooled.status}`);
|
|
204
164
|
const bytes = Buffer.from(await spooled.arrayBuffer());
|
|
205
165
|
if (shipped === null) {
|
|
@@ -219,10 +179,10 @@ class Mirror {
|
|
|
219
179
|
} else {
|
|
220
180
|
const query = new URLSearchParams({
|
|
221
181
|
path: shipped,
|
|
222
|
-
session: listing.session
|
|
223
|
-
revision: String(listing.revision
|
|
182
|
+
session: listing.session,
|
|
183
|
+
revision: String(listing.revision),
|
|
224
184
|
source: this.source,
|
|
225
|
-
|
|
185
|
+
callId,
|
|
226
186
|
});
|
|
227
187
|
// WHAT THE BYTES WERE MADE FROM, when the session holds a document:
|
|
228
188
|
// the `.blend` this export came out of. Passed as a repeatable
|
|
@@ -230,7 +190,7 @@ class Mirror {
|
|
|
230
190
|
// server validates each one as a project-relative path of its own.
|
|
231
191
|
if (typeof listing.document === 'string' && listing.document !== '')
|
|
232
192
|
query.append('inputs', listing.document);
|
|
233
|
-
const recorded = await
|
|
193
|
+
const recorded = await sessionFetch(`${await tab.origin()}/__editor/blender-output?${query}`, {
|
|
234
194
|
method: 'POST',
|
|
235
195
|
body: new Uint8Array(bytes),
|
|
236
196
|
});
|
|
@@ -242,10 +202,8 @@ class Mirror {
|
|
|
242
202
|
}
|
|
243
203
|
}
|
|
244
204
|
this.#seen.set(entry.path, stamp);
|
|
245
|
-
written += 1;
|
|
246
205
|
}
|
|
247
206
|
}
|
|
248
|
-
return written;
|
|
249
207
|
}
|
|
250
208
|
}
|
|
251
209
|
|
|
@@ -253,7 +211,6 @@ class TabSession {
|
|
|
253
211
|
#client: EditorClient | null = null;
|
|
254
212
|
#connecting: Promise<EditorClient> | null = null;
|
|
255
213
|
#origin: string | null = null;
|
|
256
|
-
#port: number | null = null;
|
|
257
214
|
/** A replay starts with a fresh model once; ordinary agent sessions persist. */
|
|
258
215
|
#freshSessionPending = Boolean(process.env['VOLTER_BLENDER_FRESH_SESSION']);
|
|
259
216
|
constructor(
|
|
@@ -268,7 +225,6 @@ class TabSession {
|
|
|
268
225
|
await this.ensureEditor();
|
|
269
226
|
const { resolveSession } = await import('@volter/live');
|
|
270
227
|
const session = await resolveSession(this.project);
|
|
271
|
-
this.#port = session.port;
|
|
272
228
|
this.#origin = `http://127.0.0.1:${session.port}`;
|
|
273
229
|
this.#client = new EditorClient({ url: this.#origin });
|
|
274
230
|
return this.#client;
|
|
@@ -285,44 +241,36 @@ class TabSession {
|
|
|
285
241
|
return this.#origin as string;
|
|
286
242
|
}
|
|
287
243
|
|
|
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
244
|
/**
|
|
296
245
|
* The Model document open and the session started at this project's path.
|
|
297
246
|
*
|
|
298
247
|
* Asked every call, never remembered: the session lives in the editor tab,
|
|
299
248
|
* 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.
|
|
249
|
+
* The editor owns opening the Model document and idempotent startup on each call.
|
|
303
250
|
*/
|
|
304
251
|
async started(): Promise<EditorClient> {
|
|
305
252
|
const client = await this.client();
|
|
306
253
|
const fresh = this.#freshSessionPending;
|
|
307
254
|
this.#freshSessionPending = false;
|
|
308
|
-
await client.
|
|
255
|
+
await client.contributedVerb('blender-start', { project: this.project, ...(fresh ? { fresh } : {}) });
|
|
309
256
|
return client;
|
|
310
257
|
}
|
|
311
258
|
|
|
312
259
|
async command<T>(type: string, fields: Record<string, unknown> = {}): Promise<T> {
|
|
313
260
|
const client = await this.started();
|
|
314
|
-
return client.
|
|
261
|
+
return client.contributedVerb<T & object>(type, fields);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 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. */
|
|
265
|
+
async commandHeld<T>(type: string, fields: Record<string, unknown>): Promise<T> {
|
|
266
|
+
const client = await this.client();
|
|
267
|
+
return client.contributedVerb<T & object>(type, fields);
|
|
315
268
|
}
|
|
316
269
|
}
|
|
317
270
|
|
|
318
271
|
const text = (value: string) => ({ content: [{ type: 'text' as const, text: value }] });
|
|
319
272
|
|
|
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). */
|
|
273
|
+
/** 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
274
|
function mirrorRoots(raw: string | undefined): string[] {
|
|
327
275
|
const roots = (raw ?? '').split(delimiter).filter((root) => root !== '');
|
|
328
276
|
const mountable = (root: string) => /^[A-Za-z]:[\\/]/.test(root) || (root.startsWith('/') && !root.startsWith('//'));
|
|
@@ -342,17 +290,14 @@ export async function serveBlenderMcp(
|
|
|
342
290
|
command: string,
|
|
343
291
|
): Promise<void> {
|
|
344
292
|
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.
|
|
293
|
+
// Photographs remain visible in the editor's own image documents.
|
|
348
294
|
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
295
|
// Blender's own spelling of the root: on Windows the project's `C:\…` is `/C:/…` inside it, and
|
|
350
296
|
// a script told the host spelling writes to a path that is not the project's.
|
|
351
297
|
const mounted = engineSpelling(project);
|
|
352
298
|
// 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()).`;
|
|
299
|
+
// stageProjectFiles), so a project script runs from its file; the instructions say so.
|
|
300
|
+
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
301
|
const instructions = `${runtimeIdentity}\n${filesystem}\n${photographs}`;
|
|
357
302
|
const tools = (blenderTools as ToolShape[]).map((tool) =>
|
|
358
303
|
tool.name === 'execute_blender_code'
|
|
@@ -389,7 +334,7 @@ export async function serveBlenderMcp(
|
|
|
389
334
|
// The transport's own number for THIS call. A file the script writes is
|
|
390
335
|
// recorded under it, so a ledger entry points back at the exact request
|
|
391
336
|
// that produced the bytes.
|
|
392
|
-
const callId =
|
|
337
|
+
const callId = String(extra.requestId);
|
|
393
338
|
if (name in STATIC_TOOLS) return text(STATIC_TOOLS[name]!);
|
|
394
339
|
if (name in defaults) return text(defaults[name]!);
|
|
395
340
|
switch (name) {
|
|
@@ -419,7 +364,20 @@ export async function serveBlenderMcp(
|
|
|
419
364
|
const { result } = await tab.command<{ result: string }>('blender-execute', {
|
|
420
365
|
code: String(args['code'] ?? ''),
|
|
421
366
|
});
|
|
422
|
-
|
|
367
|
+
// The script has run and what it saved is saved: a failure to copy its other files is said BESIDE its
|
|
368
|
+
// answer, preserving the script’s successful result.
|
|
369
|
+
try {
|
|
370
|
+
await mirror.sync(tab, callId);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
return {
|
|
373
|
+
...text(
|
|
374
|
+
`${result}\n\nThe script ran, but the files it wrote did not all reach the project's disk: ` +
|
|
375
|
+
`${error instanceof Error ? error.message : String(error)}. The next execute_blender_code call ` +
|
|
376
|
+
'copies what is still missing.',
|
|
377
|
+
),
|
|
378
|
+
isError: true,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
423
381
|
return text(result);
|
|
424
382
|
}
|
|
425
383
|
case 'get_viewport_screenshot': {
|
|
@@ -429,7 +387,6 @@ export async function serveBlenderMcp(
|
|
|
429
387
|
view?: { size: number; position?: number[]; target?: number[] };
|
|
430
388
|
error?: string;
|
|
431
389
|
}>('blender-screenshot-view', { maxSize });
|
|
432
|
-
if (!answer.document) throw new Error('Screenshot failed: Blender supplied no document address');
|
|
433
390
|
if (!answer.view)
|
|
434
391
|
throw new Error(answer.error ?? 'Screenshot failed: No 3D viewport found');
|
|
435
392
|
const { view } = answer;
|