@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
|
@@ -1,38 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The editor tab's Blender session and the control-channel doors onto it —
|
|
3
|
-
* Blender's EDITOR half, in `@volter/editor-blender` (WORK.md §Skews as packages under
|
|
4
|
-
* the Code-OSS frame, item 6). Its thirteen verbs are
|
|
5
|
-
* `../contributions/blender.command.ts`, a `workspace.command` contribution;
|
|
6
|
-
* what it needs of the running editor it asks THROUGH THE DOOR,
|
|
7
|
-
* `@volter/sdk/host` — the document context it
|
|
8
|
-
* presents into, the session it lives and dies with. It imports no host module
|
|
9
|
-
* (WORKBENCH.md §The invariants, "Direction": a package imports only the SDK
|
|
10
|
-
* and other packages' exports), which is what lets this lane ship against a
|
|
11
|
-
* workbench it was not built in.
|
|
12
|
-
*
|
|
13
|
-
* `volter blender-mcp` is transport only: every `execute_blender_code`,
|
|
14
|
-
* `get_scene_info`, `get_object_info` and `get_viewport_screenshot` arrives
|
|
15
|
-
* here as a `blender-*` command and is answered by Blender running in this
|
|
16
|
-
* tab's worker (`@volter/blender-engine/browser`). The model exists in that worker
|
|
17
|
-
* and nowhere else; the Model document (`document:blender:runtime`, the
|
|
18
|
-
* project's `blender-runtime` contribution) only displays the frames the
|
|
19
|
-
* worker presents. A screenshot is the tab photographing that document
|
|
20
|
-
* through `capture-active-document`, after `blender-screenshot-view` has
|
|
21
|
-
* presented the model with the viewport's own camera.
|
|
22
|
-
*
|
|
23
|
-
* VOLTER_THREE photographs a detached revision through its own camera.
|
|
24
|
-
* Native CYCLES renders inside the Blender worker. A native preview is a
|
|
25
|
-
* read of that document through Cycles, separate from its Three presentation.
|
|
26
|
-
*/
|
|
1
|
+
/** The tab’s Blender session and SDK host doors. blender.verbs.ts registers the page commands; Blender runs in the worker and the Model displays its frames. VOLTER_THREE photographs detached revisions through its camera; native Cycles renders inside the worker. */
|
|
27
2
|
|
|
3
|
+
import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
|
|
28
4
|
import {
|
|
29
5
|
BlenderRuntime,
|
|
30
|
-
type CaptureRequest,
|
|
31
6
|
type PresentAnswer,
|
|
7
|
+
type SavedDocument,
|
|
8
|
+
type SaveScript,
|
|
32
9
|
} from '@volter/blender-engine/browser';
|
|
33
|
-
import type { RenderRequest } from '@volter/blender-engine/browser/protocol';
|
|
10
|
+
import type { BlenderAssetLibrary, GltfExportAnswer, GltfExportKind, GltfExportPrevious, RenderRequest, RuntimeStart } from '@volter/blender-engine/browser/protocol';
|
|
34
11
|
import type {
|
|
35
12
|
BlenderActionClip,
|
|
13
|
+
BlenderSceneMovie,
|
|
36
14
|
BlenderNodeTree,
|
|
37
15
|
BlenderOutlinerTree,
|
|
38
16
|
BlenderOutlinerWrite,
|
|
@@ -43,9 +21,10 @@ import type {
|
|
|
43
21
|
} from '@volter/blender-engine/browser/rna';
|
|
44
22
|
import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from '@volter/blender-engine/browser/three/blender-display-transform';
|
|
45
23
|
import { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
|
|
46
|
-
import type
|
|
47
|
-
import { editorHost } from '@volter/sdk/host';
|
|
48
|
-
import {
|
|
24
|
+
import { commandJobs, type EditorCommandResult } from '@volter/sdk/commands';
|
|
25
|
+
import { editorHost, type EditorHostProject } from '@volter/sdk/host';
|
|
26
|
+
import { waitUntil } from '@volter/sdk/wait-until';
|
|
27
|
+
import type { ViewVerb } from '@volter/sdk/commands';
|
|
49
28
|
import {
|
|
50
29
|
captureSceneImage,
|
|
51
30
|
captureSceneLinear,
|
|
@@ -53,6 +32,10 @@ import {
|
|
|
53
32
|
type LinearCaptureFrame,
|
|
54
33
|
} from '@volter/editor-threejs/capture/scene';
|
|
55
34
|
import {blenderRenderCamera} from '@volter/blender-engine/browser/three/blender-render-camera';
|
|
35
|
+
import { beginMovie, type Movie } from '@volter/editor-movie';
|
|
36
|
+
import { mixSceneSound } from './scene-sound';
|
|
37
|
+
import { clearModelDocumentSaving, setModelDocumentSaveNotice } from '../src/model-document-saving';
|
|
38
|
+
import { startDocument } from '../src/start-document';
|
|
56
39
|
import * as THREE from 'three';
|
|
57
40
|
import { blenderEngineSelection, refreshBlenderOutliner } from '../contributions/blender-outliner-model';
|
|
58
41
|
import {
|
|
@@ -65,9 +48,7 @@ import {
|
|
|
65
48
|
|
|
66
49
|
/** Raw bytes of a base64 payload, as the half-float words a linear frame is. */
|
|
67
50
|
function halfFloatFrame(base64: string): Uint16Array {
|
|
68
|
-
const
|
|
69
|
-
const bytes = new Uint8Array(binary.length);
|
|
70
|
-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
51
|
+
const bytes = bytesFromBase64(base64);
|
|
71
52
|
return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
|
|
72
53
|
}
|
|
73
54
|
|
|
@@ -180,21 +161,7 @@ export async function photographSnapshot(
|
|
|
180
161
|
|
|
181
162
|
export const BLENDER_RUNTIME_DOCUMENT_ID = 'document:blender:runtime';
|
|
182
163
|
|
|
183
|
-
/**
|
|
184
|
-
* THE MODEL DOCUMENT THIS TAB IS PRESENTING INTO, and the `.blend` it opened.
|
|
185
|
-
*
|
|
186
|
-
* A `model` entry's document is bound to the project's own `.blend`
|
|
187
|
-
* (`models.finder.ts` lists them; `blender-runtime.document.tsx` opens the one
|
|
188
|
-
* its entry names), so the presenter's address is no longer a constant. A
|
|
189
|
-
* project with no `.blend` of its own still gets ONE Model document at the
|
|
190
|
-
* standing `blender:runtime` address — that is what `blender-start` presents
|
|
191
|
-
* into and what the battery photographs — and then this stays null and every
|
|
192
|
-
* read falls back to the constant above.
|
|
193
|
-
*
|
|
194
|
-
* It is a module variable rather than a host door because both sides are THIS
|
|
195
|
-
* package: the document that binds it and the session that reads it ship
|
|
196
|
-
* together, so there is nothing for the SDK to carry.
|
|
197
|
-
*/
|
|
164
|
+
/** The bound Model document supplies its presentation address and .blend path. With no project .blend, use the standing blender:runtime document. Both producer and reader belong to this package. */
|
|
198
165
|
export interface ModelDocumentBinding {
|
|
199
166
|
readonly documentId: string;
|
|
200
167
|
readonly entryId: string;
|
|
@@ -229,11 +196,7 @@ function shownModelBlend(): string | undefined {
|
|
|
229
196
|
: boundModel?.blend;
|
|
230
197
|
}
|
|
231
198
|
|
|
232
|
-
/**
|
|
233
|
-
* A script's Save As into a project `.blend` that no Model document lists (`followMovedDocument`): Blender edits
|
|
234
|
-
* that file and saves there, and the tab it left (`from`) keeps showing Blender, since there is no tab to move to.
|
|
235
|
-
* Without this, every later call was refused as a conflict naming a file no tab can show.
|
|
236
|
-
*/
|
|
199
|
+
/** Follow a script’s Save As even when no Model document lists the new project file. Keep the existing tab bound to the session’s current file. */
|
|
237
200
|
let unlistedFollow: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
|
|
238
201
|
|
|
239
202
|
function modelDocumentConflict(): string | null {
|
|
@@ -241,9 +204,10 @@ function modelDocumentConflict(): string | null {
|
|
|
241
204
|
const requested = shownModelBlend();
|
|
242
205
|
if (!requested || !held || requested === held) return null;
|
|
243
206
|
if (unlistedFollow !== null && unlistedFollow.owner === runtime && unlistedFollow.document === held && unlistedFollow.from === requested) return null;
|
|
244
|
-
// Say how to get Blender onto the document the editor shows, not only how to go back:
|
|
245
|
-
//
|
|
246
|
-
//
|
|
207
|
+
// Say how to get Blender onto the document the editor shows, not only how to go back: "return to <held>"
|
|
208
|
+
// alone reads as the Blender door being unusable.
|
|
209
|
+
// (`session.py`'s `_another_document_shown` reads "but the editor is showing": a first present refused so is a
|
|
210
|
+
// document switch, not a failure of the document's.)
|
|
247
211
|
// Plain words: people see this in error notices too. Re-opening the document already showing does not
|
|
248
212
|
// remount a failed open (blender-runtime.document.tsx), so the steps that do are named instead.
|
|
249
213
|
return `Blender is editing ${held}, but the editor is showing ${requested}, which Blender has not loaded. ` +
|
|
@@ -273,6 +237,16 @@ export function modelDocumentMayOpen(binding: ModelDocumentBinding): boolean {
|
|
|
273
237
|
return !anotherModel || active === binding.documentId;
|
|
274
238
|
}
|
|
275
239
|
|
|
240
|
+
/** THE `.blend` A MODEL VIEW SHOWS NOW: the runtime's document, once `view` holds that runtime's
|
|
241
|
+
* latest present; null between opening a file and its first present (the view still shows the
|
|
242
|
+
* last one). */
|
|
243
|
+
export function presentedModelBlend(view: { snapshot(): { readonly session: string; readonly revision: number } | null }): string | null {
|
|
244
|
+
const latest = runtime?.presented;
|
|
245
|
+
const shown = view.snapshot();
|
|
246
|
+
if (!runtime || !latest || !shown || shown.session !== latest.session || shown.revision !== latest.revision) return null;
|
|
247
|
+
return runtime.document ?? null;
|
|
248
|
+
}
|
|
249
|
+
|
|
276
250
|
/** A retained photograph must belong to the file and revision still on screen,
|
|
277
251
|
* not a retiring pane or the next worker's first frame. */
|
|
278
252
|
export function modelDocumentOwnsPresentation(
|
|
@@ -289,6 +263,151 @@ export function modelDocumentOwnsPresentation(
|
|
|
289
263
|
!!presented && !!latest && presented.session === latest.session && presented.revision === latest.revision;
|
|
290
264
|
}
|
|
291
265
|
|
|
266
|
+
/**
|
|
267
|
+
* THE MODEL DOCUMENT'S WORKING COPY — what the workbench calls for the `.blend` the engine holds
|
|
268
|
+
* (the `workingCopy` of `blender-runtime.document.tsx`). The engine is its one writer: a save is
|
|
269
|
+
* the worker's own, over the session's delta route, and the rest reopen the document inside the
|
|
270
|
+
* engine.
|
|
271
|
+
*/
|
|
272
|
+
const modelDirtyListeners = new Set<() => void>();
|
|
273
|
+
|
|
274
|
+
export function subscribeModelDocumentDirty(listener: () => void): () => void {
|
|
275
|
+
modelDirtyListeners.add(listener);
|
|
276
|
+
return () => { modelDirtyListeners.delete(listener); };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function modelDocumentDirty(blend: string): boolean {
|
|
280
|
+
return runtime?.document === blend && runtime.dirty;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** How long the workbench's call waits for the engine to hold the model: a cold boot of the engine. */
|
|
284
|
+
const MODEL_HELD_MS = 120_000;
|
|
285
|
+
|
|
286
|
+
/** The runtime holding `blend`, once it does. */
|
|
287
|
+
async function runtimeHolding(blend: string): Promise<BlenderRuntime> {
|
|
288
|
+
if (!(await waitUntil(() => runtime?.document === blend, MODEL_HELD_MS)) || !runtime) {
|
|
289
|
+
throw new Error(`Blender does not hold ${blend}.`);
|
|
290
|
+
}
|
|
291
|
+
return runtime;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Bring the project's `.blend` up to the model the engine holds, for a reader that takes the
|
|
295
|
+
* file's bytes as the model's (a cook's or an export's hash beside what the engine exported). */
|
|
296
|
+
export async function flushModelDocument(blend: string): Promise<void> {
|
|
297
|
+
const owner = runtime;
|
|
298
|
+
if (owner?.document !== blend || !owner.dirty) return;
|
|
299
|
+
const saved = await owner.saveDocument(saveScript ?? undefined);
|
|
300
|
+
if (saved !== null) modelDocumentSaved(owner, saved);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function saveModelDocument(blend: string): Promise<void> {
|
|
304
|
+
const owner = await runtimeHolding(blend);
|
|
305
|
+
const saved = await owner.saveDocument(saveScript ?? undefined);
|
|
306
|
+
if (saved !== null) modelDocumentSaved(owner, saved);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** THE SCRIPT EVERY SAVE RUNS, in the save's own turn, on the scene it wrote (`BlenderRuntime.saveDocument`), as its
|
|
310
|
+
* setter names it for the document saved; one at a time, until its removal. */
|
|
311
|
+
let saveScript: SaveScript | null = null;
|
|
312
|
+
|
|
313
|
+
export function setModelDocumentSaveScript(script: SaveScript): () => void {
|
|
314
|
+
if (saveScript) throw new Error('A save script is already set for model documents.');
|
|
315
|
+
saveScript = script;
|
|
316
|
+
return () => { if (saveScript === script) saveScript = null; };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A SAVE THAT LANDED A `.blend` IN THE PROJECT, as its listeners hear it: the file it wrote, what the save script did
|
|
320
|
+
* on the scene it wrote (null where none ran: `setModelDocumentSaveScript`), and the carry of that script's files into
|
|
321
|
+
* the project at the same paths, as an export's are (`blender-gltf-export.verbs.ts`), which the listener runs when it
|
|
322
|
+
* is ready for them. A script that raised has nothing to carry. */
|
|
323
|
+
export interface ModelDocumentSaved {
|
|
324
|
+
readonly blend: string;
|
|
325
|
+
readonly script: { readonly output: string; readonly error: string | null; readonly unstaged: readonly string[] } | null;
|
|
326
|
+
carry(): Promise<void>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** EACH SAVE THAT LANDED A `.blend` IN THE PROJECT: the working copy's (a person's Save, the workbench's autosave), an
|
|
330
|
+
* export's flush and the save that retires a document alike. What a listener answers is its work on that save (the
|
|
331
|
+
* carry and what follows it), which reads files, not Blender: a retirement waits for it ({@link retireRuntime}). */
|
|
332
|
+
type ModelDocumentSavedListener = (saved: ModelDocumentSaved) => Promise<unknown> | void;
|
|
333
|
+
const savedListeners = new Set<ModelDocumentSavedListener>();
|
|
334
|
+
/** The listeners' work on each runtime's saves, not yet done. */
|
|
335
|
+
const savedWork = new Map<BlenderRuntime, Set<Promise<unknown>>>();
|
|
336
|
+
|
|
337
|
+
function modelDocumentSaved(owner: BlenderRuntime, saved: SavedDocument): void {
|
|
338
|
+
const files = saved.script?.error === null ? saved.script.files : [];
|
|
339
|
+
const heard: ModelDocumentSaved = {
|
|
340
|
+
blend: saved.document,
|
|
341
|
+
script: saved.script === null ? null : { output: saved.script.output, error: saved.script.error, unstaged: saved.script.unstaged },
|
|
342
|
+
async carry() {
|
|
343
|
+
for (const file of files) await editorHost().files.write(file.path, file.bytes);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
for (const listener of [...savedListeners]) {
|
|
347
|
+
const work = listener(heard);
|
|
348
|
+
if (!work) continue;
|
|
349
|
+
const held = savedWork.get(owner) ?? new Set<Promise<unknown>>();
|
|
350
|
+
savedWork.set(owner, held);
|
|
351
|
+
const settled = work.then(() => {}, () => {}).finally(() => {
|
|
352
|
+
held.delete(settled);
|
|
353
|
+
if (held.size === 0 && savedWork.get(owner) === held) savedWork.delete(owner);
|
|
354
|
+
});
|
|
355
|
+
held.add(settled);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function subscribeModelDocumentSaved(listener: ModelDocumentSavedListener): () => void {
|
|
360
|
+
savedListeners.add(listener);
|
|
361
|
+
return () => { savedListeners.delete(listener); };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A RUNTIME RETIRED WITH ITS SAVES' WORK DONE, for every retirement (another document opened, a reload, a stop, the
|
|
366
|
+
* session's close): its last save runs the save script in its turn as every save does and is heard, and then the
|
|
367
|
+
* listeners' work on every save the runtime made, which reads files and not Blender, is waited for. A save that fails
|
|
368
|
+
* throws, and the runtime stays alive with its document.
|
|
369
|
+
*/
|
|
370
|
+
async function retireRuntime(owner: BlenderRuntime): Promise<void> {
|
|
371
|
+
await owner.stop({ ...(saveScript ? { after: saveScript } : {}), landed: (saved) => modelDocumentSaved(owner, saved) });
|
|
372
|
+
for (let held = savedWork.get(owner); held !== undefined; held = savedWork.get(owner)) await Promise.all([...held]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* THE ASSET LIBRARIES EVERY SESSION'S BLENDER HAS (Preferences > File Paths > Asset Libraries), by name: set in the
|
|
377
|
+
* running session at once, and in each session after it starts (`startSession`). Blender's preferences live as long as
|
|
378
|
+
* its session, and each `.blend` opened is a new session. The removal leaves a running session's library as it is.
|
|
379
|
+
*/
|
|
380
|
+
const assetLibraries = new Map<string, BlenderAssetLibrary>();
|
|
381
|
+
|
|
382
|
+
export function setBlenderAssetLibrary(library: BlenderAssetLibrary): () => void {
|
|
383
|
+
assetLibraries.set(library.name, library);
|
|
384
|
+
if (blenderSessionStarted()) void blenderRuntime().setAssetLibrary(library).catch(refusedLibrary(library));
|
|
385
|
+
return () => { if (assetLibraries.get(library.name) === library) assetLibraries.delete(library.name); };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const refusedLibrary = (library: BlenderAssetLibrary) => (error: unknown): void =>
|
|
389
|
+
editorHost().console.error(`Blender did not take the asset library ${library.name} (${library.directory}): ${error instanceof Error ? error.message : String(error)}`, 'blender-asset-library');
|
|
390
|
+
|
|
391
|
+
/** A session's start, then the asset libraries every session has. Called where a start is asked for; the start
|
|
392
|
+
* claims its resource synchronously, before the first await. */
|
|
393
|
+
async function startSession(session: BlenderRuntime, project: string, document: string): Promise<RuntimeStart> {
|
|
394
|
+
const started = await session.start(project, document);
|
|
395
|
+
for (const library of assetLibraries.values()) await session.setAssetLibrary(library).catch(refusedLibrary(library));
|
|
396
|
+
return started;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function snapshotModelDocument(blend: string): Promise<Uint8Array> {
|
|
400
|
+
return (await runtimeHolding(blend)).snapshotDocument();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export async function revertModelDocument(blend: string): Promise<void> {
|
|
404
|
+
await (await runtimeHolding(blend)).revertDocument();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function restoreModelDocument(blend: string, bytes: Uint8Array): Promise<void> {
|
|
408
|
+
await (await runtimeHolding(blend)).restoreDocument(bytes);
|
|
409
|
+
}
|
|
410
|
+
|
|
292
411
|
/**
|
|
293
412
|
* OPEN A `.blend` IN THE ENGINE — the Model document's own call, the WS-F save
|
|
294
413
|
* path run backwards. `session.py` opens the named file at start and saves
|
|
@@ -311,6 +430,7 @@ function queueModelLifecycle<T>(operation: () => Promise<T>): Promise<T> {
|
|
|
311
430
|
export async function openModelDocumentBlend(
|
|
312
431
|
binding: ModelDocumentBinding,
|
|
313
432
|
publish: () => void,
|
|
433
|
+
reload = false,
|
|
314
434
|
): Promise<boolean> {
|
|
315
435
|
let published = false;
|
|
316
436
|
const publishOnce = () => {
|
|
@@ -332,12 +452,13 @@ export async function openModelDocumentBlend(
|
|
|
332
452
|
}
|
|
333
453
|
// Only one handoff may own stop/start. Re-check the mounted requester after
|
|
334
454
|
// every wait: a rapid B -> C selection must not let B publish into C.
|
|
335
|
-
return queueModelLifecycle(() => openBoundModelDocument(binding, publishOnce));
|
|
455
|
+
return queueModelLifecycle(() => openBoundModelDocument(binding, publishOnce, reload));
|
|
336
456
|
}
|
|
337
457
|
|
|
338
458
|
async function openBoundModelDocument(
|
|
339
459
|
binding: ModelDocumentBinding,
|
|
340
460
|
publish: () => void,
|
|
461
|
+
reload: boolean,
|
|
341
462
|
): Promise<boolean> {
|
|
342
463
|
const host = editorHost();
|
|
343
464
|
if (!host.session.open()) throw new Error('Opening a model requires an open project session.');
|
|
@@ -345,40 +466,34 @@ async function openBoundModelDocument(
|
|
|
345
466
|
if (project === null) throw new Error('Opening a model requires a project path.');
|
|
346
467
|
if (!modelDocumentMayOpen(binding)) return false;
|
|
347
468
|
const previous = runtime;
|
|
348
|
-
if (previous && (previous.project !== project || previous.document !== binding.blend)) {
|
|
349
|
-
//
|
|
469
|
+
if (previous && (reload || previous.project !== project || previous.document !== binding.blend)) {
|
|
470
|
+
// The OLD worker's bound file is saved and its saves' work done before it stops. If
|
|
350
471
|
// saving fails it stays alive; neither its history nor its ownership moves.
|
|
351
|
-
|
|
352
|
-
|
|
472
|
+
// (named, both, so the session journal says which document gave way to which)
|
|
473
|
+
const end = beginBlenderWork(`saving ${previous.document ?? 'the previous model'} before opening ${binding.blend}${reload ? ' (a reload)' : ''}`);
|
|
474
|
+
try { await retireRuntime(previous); } finally { end(); }
|
|
353
475
|
if (runtime === previous) terminateBlenderRuntime();
|
|
354
476
|
if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
|
|
355
477
|
}
|
|
356
478
|
const session = blenderRuntime();
|
|
357
479
|
// start claims its resource synchronously; its first frame may arrive before
|
|
358
480
|
// the returned promise resolves. The getter above rejects a conflicting file.
|
|
359
|
-
const started = session
|
|
481
|
+
const started = startSession(session, project, binding.blend);
|
|
360
482
|
const starting = { session, project, blend: binding.blend };
|
|
361
483
|
modelStartup = starting;
|
|
362
484
|
try {
|
|
363
485
|
publish();
|
|
364
486
|
await started;
|
|
365
487
|
} catch (error) {
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// same failed start. A refusal that is not a failed start (another document holds the
|
|
369
|
-
// session) leaves the session alone: it is someone else's.
|
|
488
|
+
// Discard a failed start so reopening creates a new worker. A session owned by another
|
|
489
|
+
// document remains alive and retains its resource.
|
|
370
490
|
if (session.startFailed && runtime === session) terminateBlenderRuntime();
|
|
371
491
|
throw error;
|
|
372
492
|
} finally {
|
|
373
493
|
if (modelStartup === starting) modelStartup = null;
|
|
374
494
|
}
|
|
375
495
|
if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
|
|
376
|
-
// bind_document presents
|
|
377
|
-
// immediately evaluate/export it again: on Stoneguard that redundant RPC
|
|
378
|
-
// took 13.8 seconds after the resumable startup had already finished.
|
|
379
|
-
// A reopened pane has a NEW view, though, and an absent file presents
|
|
380
|
-
// nothing at start. Compare the view's actual holding with the runtime's
|
|
381
|
-
// revision so both cases still request the frame they need.
|
|
496
|
+
// bind_document presents before start resolves. Request another frame only when the view does not hold the runtime’s current revision, including reopened panes and initially absent files.
|
|
382
497
|
const shown = (await runtimeView()).snapshot();
|
|
383
498
|
if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
|
|
384
499
|
const latest = session.presented;
|
|
@@ -397,7 +512,7 @@ async function openBoundModelDocument(
|
|
|
397
512
|
* and the panel read one thing.
|
|
398
513
|
*
|
|
399
514
|
* THEY NEVER START THE ENGINE. A panel asking what the engine holds must not
|
|
400
|
-
*
|
|
515
|
+
* start Blender, so these answer `null` when this tab
|
|
401
516
|
* has no started session — exactly the read `blender-status` makes, and for
|
|
402
517
|
* the same reason.
|
|
403
518
|
*/
|
|
@@ -405,34 +520,7 @@ export function blenderSessionStarted(): boolean {
|
|
|
405
520
|
return runtime?.project != null && modelDocumentConflict() === null;
|
|
406
521
|
}
|
|
407
522
|
|
|
408
|
-
/**
|
|
409
|
-
* THE DOCUMENT'S RNA VERSION — "a tree's freshness is the tree's, not the
|
|
410
|
-
* presented frame's" (orchestrator ruling 3, 2026-09-19, WORK.md §Blender in
|
|
411
|
-
* the tab is Blender, I5).
|
|
412
|
-
*
|
|
413
|
-
* Every view over this engine draws something RNA answers, and until this
|
|
414
|
-
* existed the only "the engine moved" signal in the page was a PRESENTED
|
|
415
|
-
* FRAME: `blender-properties-model.ts` subscribes to the Model document's
|
|
416
|
-
* frames, and its version was what the node view re-read on. MEASURED
|
|
417
|
-
* 2026-09-19 by I5's socket-panel step: collapsing a panel through
|
|
418
|
-
* `blender-rna-set` changes `Node.panel_states[n].is_collapsed`, which changes
|
|
419
|
-
* nothing the presenter draws — so no frame ships, no version moves, and the
|
|
420
|
-
* node view kept drawing the old tree until the document was reopened. It is
|
|
421
|
-
* the same shape as I3's "a present is not a reliable signal for a column"
|
|
422
|
-
* (`hide_render`), and the answer is the same: the WRITE asks for the re-read.
|
|
423
|
-
*
|
|
424
|
-
* So the version is minted HERE, at the door every RNA write goes through,
|
|
425
|
-
* and it is bumped by the write rather than by the picture. Both halves of
|
|
426
|
-
* each door bump it once: the in-page function is what the wire's
|
|
427
|
-
* `blender-rna-set` / `blender-outliner-set` case calls, so a `cyclotron eval` and
|
|
428
|
-
* a panel click are one path. `blender-execute` bumps it too — arbitrary bpy
|
|
429
|
-
* can change anything RNA answers, and a view that went stale under a probe's
|
|
430
|
-
* own script would be the same defect one layer out.
|
|
431
|
-
*
|
|
432
|
-
* WHAT SUBSCRIBES: `blender-properties-model.ts` (which invalidates its cached
|
|
433
|
-
* datablock views and re-reads the context, exactly as it does on a frame),
|
|
434
|
-
* and every view in `@volter/editor-blender/contributions` that draws RNA.
|
|
435
|
-
*/
|
|
523
|
+
/** The RNA version invalidates every view over this engine. Bump it at script, RNA and Outliner write doors, including writes that change no rendered geometry; presented frames bump it too. */
|
|
436
524
|
let rnaVersion = 0;
|
|
437
525
|
const rnaListeners = new Set<() => void>();
|
|
438
526
|
|
|
@@ -455,9 +543,9 @@ export function noteBlenderRnaChanged(): void {
|
|
|
455
543
|
for (const listener of [...rnaListeners]) listener();
|
|
456
544
|
}
|
|
457
545
|
|
|
458
|
-
export async function blenderRna(path: string
|
|
546
|
+
export async function blenderRna(path: string): Promise<BlenderRnaView | null> {
|
|
459
547
|
if (!blenderSessionStarted()) return null;
|
|
460
|
-
return blenderRuntime().rna(path
|
|
548
|
+
return blenderRuntime().rna(path);
|
|
461
549
|
}
|
|
462
550
|
|
|
463
551
|
export async function blenderRnaContext(
|
|
@@ -487,51 +575,7 @@ export async function blenderViewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATER
|
|
|
487
575
|
await blenderRuntime().viewShading(shading);
|
|
488
576
|
}
|
|
489
577
|
|
|
490
|
-
/**
|
|
491
|
-
* THE SCRIPT DOOR, in-page — one bpy script, run the way the MCP add-on runs
|
|
492
|
-
* one (`session.py::execute`: a namespace of its own, output captured, and a
|
|
493
|
-
* PRESENT after it, so the picture that comes back is the engine's own
|
|
494
|
-
* reading).
|
|
495
|
-
*
|
|
496
|
-
* This is what an OPERATOR goes through. The Outliner's structural verbs —
|
|
497
|
-
* add, delete, duplicate — are `bpy.ops.*` calls and nothing else: Blender
|
|
498
|
-
* runs the operator and we only name it (`blender-outliner-authoring.ts`'s
|
|
499
|
-
* `StructureProvider`). Writing a second implementation of `object.delete`'s
|
|
500
|
-
* unparenting, or of `primitive_uv_sphere_add`'s defaults, is the thing this
|
|
501
|
-
* door exists to make unnecessary.
|
|
502
|
-
*
|
|
503
|
-
* SAME SHAPE AS {@link blenderRnaSet}, and for its reason: the RNA version is
|
|
504
|
-
* minted at the door, so the wire's `blender-execute` case calls THIS rather
|
|
505
|
-
* than `session.execute` beside it — arbitrary bpy can change anything RNA
|
|
506
|
-
* answers, and a view left drawing the tree it had is the defect the version
|
|
507
|
-
* exists for. Like every door here it never starts the engine.
|
|
508
|
-
*
|
|
509
|
-
* The answer is `session.py::execute`'s own record — `executed`, the captured
|
|
510
|
-
* `result` text, and `error` when the script raised. A REFUSAL IS NOT A THROW:
|
|
511
|
-
* Blender's traceback comes back in `error`, verbatim, and a caller that wants
|
|
512
|
-
* it to be an exception raises its own (the structure provider does, so the
|
|
513
|
-
* ack it hands the shell carries Blender's sentence).
|
|
514
|
-
*
|
|
515
|
-
* IT DOES NOT GUARD ON `blenderSessionStarted()`, and that is the one way it
|
|
516
|
-
* differs from the read doors above. They are called from a panel's render and
|
|
517
|
-
* must answer "nothing to show" rather than spawn a session; a script is always
|
|
518
|
-
* a deliberate act, and the engine's own refusal — "The Blender session has not
|
|
519
|
-
* been started with a project (blender-start)" — is the honest answer to one
|
|
520
|
-
* made too early. A silent `null` there would have turned the wire's own loud
|
|
521
|
-
* refusal into an empty result.
|
|
522
|
-
*
|
|
523
|
-
* WHAT THE SESSION ACTUALLY ANSWERS WITH IS THE MCP DOOR'S TEXT, not
|
|
524
|
-
* `session.py::execute`'s `{executed, result, error}` record: the worker
|
|
525
|
-
* flattens it into ONE STRING on the way out — `Code executed successfully:
|
|
526
|
-
* <stdout>` or `Error executing code: <traceback>` (`worker.ts:250-260`, "a
|
|
527
|
-
* script's failure is TEXT, not a rejection"), because that is what the MCP
|
|
528
|
-
* tool returns to an agent. So this parses it, once, here. MEASURED 2026-09-21:
|
|
529
|
-
* a first version read `answer.executed`/`answer.error` off the string, where
|
|
530
|
-
* both are `undefined` — every operator RAN and every caller was told it had
|
|
531
|
-
* failed, which is how an added sphere reached `bpy.data.objects` and the
|
|
532
|
-
* Outliner and was never selected. Read-only metadata uses dedicated native
|
|
533
|
-
* reads rather than this execute path, which always presents a mutation.
|
|
534
|
-
*/
|
|
578
|
+
/** Execute one bpy script in the current session and bump the RNA version. The worker returns MCP text: Code executed successfully: <stdout> or Error executing code: <traceback>. Parse it once into executed/result/error for in-page callers; preserve the text for wire callers. A call before session startup receives the engine’s refusal. */
|
|
535
579
|
export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
|
|
536
580
|
const session = blenderRuntime();
|
|
537
581
|
const text = await session.execute(code, history, label);
|
|
@@ -547,6 +591,187 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
|
|
|
547
591
|
};
|
|
548
592
|
}
|
|
549
593
|
|
|
594
|
+
// ---------------------------------------------------------------- a film, rendered
|
|
595
|
+
|
|
596
|
+
/** WHERE A RENDER'S TIME GOES, one record per photograph a render or a film takes: the frame's
|
|
597
|
+
* transfer (from its first staged part to its present; none when nothing new was staged), building
|
|
598
|
+
* it into the view it is photographed from, and the photograph itself. Read by `render-movie`
|
|
599
|
+
* beside Blender's own time, so a slow film says which side is slow. */
|
|
600
|
+
const renderTimings: { stageMs: number; applyMs: number; photoMs: number }[] = [];
|
|
601
|
+
/** When the frame being transferred staged its first part, render or viewport; taken at its present. */
|
|
602
|
+
let frameStagedAt: number | null = null;
|
|
603
|
+
|
|
604
|
+
/** The film being encoded (`renderBlenderMovie`), one at a time, and the photographs of the frames
|
|
605
|
+
* rendered and not yet encoded, in order (base64 PNG). */
|
|
606
|
+
let film: { readonly movie: Movie; readonly photographs: string[] } | null = null;
|
|
607
|
+
|
|
608
|
+
async function movieFrames(numbers: readonly number[]): Promise<{ encoded: number; timing: { executeMs: number; stageMs: number; applyMs: number; photoMs: number; encodeMs: number } }> {
|
|
609
|
+
const current = film;
|
|
610
|
+
if (!current) throw new Error('No film is being rendered.');
|
|
611
|
+
const { movie } = current;
|
|
612
|
+
// ONE CALL TAKES THE CHUNK: each frame set, then photographed as the viewport presents it through
|
|
613
|
+
// the scene camera (session.py's `photograph_frame`): the present ships only what moved.
|
|
614
|
+
const code = `
|
|
615
|
+
import bpy, sys
|
|
616
|
+
photograph = next(m.photograph_frame for m in list(sys.modules.values()) if hasattr(m, "photograph_frame"))
|
|
617
|
+
s = bpy.context.scene
|
|
618
|
+
kept = s.frame_current
|
|
619
|
+
try:
|
|
620
|
+
for f in ${JSON.stringify(numbers)}:
|
|
621
|
+
s.frame_set(f)
|
|
622
|
+
photograph(${movie.width}, ${movie.height})
|
|
623
|
+
finally:
|
|
624
|
+
s.frame_set(kept)
|
|
625
|
+
`;
|
|
626
|
+
const timedFrom = renderTimings.length;
|
|
627
|
+
const executeAt = performance.now();
|
|
628
|
+
const answer = await blenderExecute(code, false, 'render-movie');
|
|
629
|
+
const executeMs = performance.now() - executeAt;
|
|
630
|
+
const taken = renderTimings.slice(timedFrom);
|
|
631
|
+
const encodeAt = performance.now();
|
|
632
|
+
if (!answer.executed) throw new Error(`Blender could not render frames ${numbers[0]}-${numbers.at(-1)}: ${answer.error}`);
|
|
633
|
+
const shots = current.photographs.splice(0);
|
|
634
|
+
if (shots.length !== numbers.length) throw new Error(`Blender rendered ${numbers.length} frames and ${shots.length} photographs were taken.`);
|
|
635
|
+
for (const shot of shots) {
|
|
636
|
+
const png = bytesFromBase64(shot);
|
|
637
|
+
const bitmap = await createImageBitmap(new Blob([png as BlobPart], { type: 'image/png' }));
|
|
638
|
+
try { await movie.frame(bitmap); } finally { bitmap.close(); }
|
|
639
|
+
}
|
|
640
|
+
const mean = (pick: (t: (typeof taken)[number]) => number): number => (taken.length ? taken.reduce((n, t) => n + pick(t), 0) / taken.length : 0);
|
|
641
|
+
const per = numbers.length || 1;
|
|
642
|
+
return { encoded: movie.frames, timing: { executeMs: executeMs / per, stageMs: mean((t) => t.stageMs), applyMs: mean((t) => t.applyMs), photoMs: mean((t) => t.photoMs), encodeMs: (performance.now() - encodeAt) / per } };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The scene's sound over frames `from` to `to`, mixed, and a movie begun with it encoded first: its track starts at
|
|
646
|
+
* the movie's first frame, so a movie from a later frame hears the sound from that frame on. */
|
|
647
|
+
async function beginFilm(size: { readonly width: number; readonly height: number }, fps: number, from: number, to: number): Promise<{ strips: number; speakers: number } | null> {
|
|
648
|
+
const session = blenderRuntime();
|
|
649
|
+
const host = editorHost();
|
|
650
|
+
const project = host.projectLocalState.projectRootPath();
|
|
651
|
+
const sound = await mixSceneSound(await session.soundScene(from, to), async (file) => {
|
|
652
|
+
if ('packed' in file) return session.readFile(file.packed);
|
|
653
|
+
// a file in the project is read from the project; the engine mirrors the project at its own path
|
|
654
|
+
return project !== null && file.path.startsWith(`${project}/`)
|
|
655
|
+
? host.files.readBytes(file.path.slice(project.length + 1))
|
|
656
|
+
: session.readFile(file.path);
|
|
657
|
+
});
|
|
658
|
+
const movie = await beginMovie({ ...size, fps, ...(sound ? { audio: { sampleRate: sound.sampleRate, channels: sound.planes.length } } : {}) });
|
|
659
|
+
try {
|
|
660
|
+
if (sound) await movie.audio(sound.planes);
|
|
661
|
+
} catch (error) {
|
|
662
|
+
movie.cancel();
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
film?.movie.cancel();
|
|
666
|
+
film = { movie, photographs: [] };
|
|
667
|
+
return sound ? { strips: sound.strips, speakers: sound.speakers } : null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** What a movie render is asked for: the project file it is written to, the `.blend` it renders (the open one by
|
|
671
|
+
* default), its first and last frames (frame numbers or marker names; the scene's range by default), its width (the
|
|
672
|
+
* scene's resolution by default; the height keeps the scene's aspect), and the frame rate a caller's own movie is at,
|
|
673
|
+
* which the scene's must equal. */
|
|
674
|
+
interface BlenderMovieRequest {
|
|
675
|
+
readonly out: string;
|
|
676
|
+
readonly document?: string;
|
|
677
|
+
readonly from?: string | number | undefined;
|
|
678
|
+
readonly to?: string | number | undefined;
|
|
679
|
+
readonly width?: number;
|
|
680
|
+
readonly fps?: number;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Where a movie render stands. */
|
|
684
|
+
interface BlenderMovieRender {
|
|
685
|
+
readonly out: string;
|
|
686
|
+
readonly state: 'rendering' | 'done' | 'failed';
|
|
687
|
+
readonly frame: number;
|
|
688
|
+
readonly frames: number;
|
|
689
|
+
readonly first?: number;
|
|
690
|
+
readonly last?: number;
|
|
691
|
+
readonly width?: number;
|
|
692
|
+
readonly height?: number;
|
|
693
|
+
readonly fps?: number;
|
|
694
|
+
/** The scene's sound the movie carries, or null when the scene plays none. */
|
|
695
|
+
readonly sound?: { readonly strips: number; readonly speakers: number } | null;
|
|
696
|
+
/** What the render cannot do as asked (a scene with no camera). */
|
|
697
|
+
readonly warnings?: readonly string[];
|
|
698
|
+
/** Where the last chunk's time went, per frame, in milliseconds: Blender's render call and, inside it, the frame's
|
|
699
|
+
* transfer, its build into the photograph view and the photograph; then the encode. */
|
|
700
|
+
readonly timing?: { readonly executeMs: number; readonly stageMs: number; readonly applyMs: number; readonly photoMs: number; readonly encodeMs: number };
|
|
701
|
+
readonly codec?: string;
|
|
702
|
+
readonly bytes?: number;
|
|
703
|
+
readonly error?: string;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** The movie renders, by the project file each writes. */
|
|
707
|
+
const movieRenders = commandJobs<BlenderMovieRender>();
|
|
708
|
+
|
|
709
|
+
/** The Timeline of `request.document` (or the open document) rendered with the scene's sound into `request.out`. */
|
|
710
|
+
async function renderBlenderMovie(request: BlenderMovieRequest, report: (render: BlenderMovieRender) => void): Promise<void> {
|
|
711
|
+
let render: BlenderMovieRender = { out: request.out, state: 'rendering', frame: 0, frames: 0 };
|
|
712
|
+
const host = editorHost();
|
|
713
|
+
if (request.document !== undefined) {
|
|
714
|
+
const opened = await host.documents.openForFile(request.document);
|
|
715
|
+
if (opened === null) throw new Error(`No Model document is ${request.document}.`);
|
|
716
|
+
await host.documents.waitForContext(opened);
|
|
717
|
+
}
|
|
718
|
+
const probe = await blenderExecute(`
|
|
719
|
+
import bpy, json
|
|
720
|
+
s = bpy.context.scene
|
|
721
|
+
r = s.render
|
|
722
|
+
print(json.dumps({"start": s.frame_start, "end": s.frame_end, "fps": r.fps / (r.fps_base or 1), "width": r.resolution_x, "height": r.resolution_y,
|
|
723
|
+
"percent": r.resolution_percentage, "camera": s.camera.name if s.camera else None,
|
|
724
|
+
"markers": {m.name: m.frame for m in s.timeline_markers}, "cameraMarkers": [m.frame for m in s.timeline_markers if m.camera]}))
|
|
725
|
+
`, false, 'render-movie');
|
|
726
|
+
if (!probe.executed) throw new Error(`Blender could not read the scene: ${probe.error}`);
|
|
727
|
+
const scene = JSON.parse(probe.result.slice(probe.result.indexOf('{'))) as {
|
|
728
|
+
start: number; end: number; fps: number; width: number; height: number; percent: number; camera: string | null;
|
|
729
|
+
markers: Record<string, number>; cameraMarkers: number[];
|
|
730
|
+
};
|
|
731
|
+
if (request.fps !== undefined && Math.abs(scene.fps - request.fps) > 1e-6)
|
|
732
|
+
throw new Error(`The scene plays at ${scene.fps} fps and the movie is at ${request.fps}: set the scene's frame rate to the movie's.`);
|
|
733
|
+
const frameOf = (at: string | number | undefined, fallback: number): number => {
|
|
734
|
+
if (at === undefined) return fallback;
|
|
735
|
+
if (typeof at === 'number') return at;
|
|
736
|
+
if (/^-?\d+$/.test(at)) return Number(at);
|
|
737
|
+
const marker = scene.markers[at];
|
|
738
|
+
if (marker === undefined) throw new Error(`The Timeline has no marker "${at}"; it has ${Object.keys(scene.markers).map((name) => `"${name}"`).join(', ') || 'none'}.`);
|
|
739
|
+
return marker;
|
|
740
|
+
};
|
|
741
|
+
const first = frameOf(request.from, scene.start);
|
|
742
|
+
const last = frameOf(request.to, scene.end);
|
|
743
|
+
if (last < first) throw new Error(`It ends (frame ${last}) before it starts (frame ${first}).`);
|
|
744
|
+
const width = request.width ?? Math.round(scene.width * (scene.percent / 100));
|
|
745
|
+
const height = Math.round(width * (scene.height / scene.width));
|
|
746
|
+
const warnings = !scene.camera && !scene.cameraMarkers.some((at) => at <= first)
|
|
747
|
+
? ['The scene has no camera; Blender renders through the scene camera or a marker-bound one.'] : [];
|
|
748
|
+
const sound = await beginFilm({ width, height }, scene.fps, first, last);
|
|
749
|
+
const movie = film!.movie;
|
|
750
|
+
render = { ...render, frames: last - first + 1, first, last, width: movie.width, height: movie.height, fps: scene.fps, sound, codec: movie.codec, warnings };
|
|
751
|
+
report(render);
|
|
752
|
+
for (let frame = first; frame <= last; frame += 12) {
|
|
753
|
+
const { encoded, timing } = await movieFrames(Array.from({ length: Math.min(12, last - frame + 1) }, (_, i) => frame + i));
|
|
754
|
+
render = { ...render, frame: encoded, timing };
|
|
755
|
+
report(render);
|
|
756
|
+
}
|
|
757
|
+
film = null;
|
|
758
|
+
const bytes = await movie.end();
|
|
759
|
+
await host.files.write(request.out, bytes);
|
|
760
|
+
report({ ...render, state: 'done', bytes: bytes.length });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Start rendering a movie, answering at once with where it stands. One render at a time. */
|
|
764
|
+
function startBlenderMovie(request: BlenderMovieRequest): BlenderMovieRender {
|
|
765
|
+
if (movieRenders.running()) throw new Error('A movie is already rendering.');
|
|
766
|
+
if (!/\.webm$/i.test(request.out)) throw new Error(`A movie is written as .webm, not ${request.out}.`);
|
|
767
|
+
return movieRenders.start(request.out, { out: request.out, state: 'rendering', frame: 0, frames: 0 }, (report) =>
|
|
768
|
+
renderBlenderMovie(request, report).catch((thrown: unknown) => {
|
|
769
|
+
film?.movie.cancel();
|
|
770
|
+
film = null;
|
|
771
|
+
throw thrown;
|
|
772
|
+
}));
|
|
773
|
+
}
|
|
774
|
+
|
|
550
775
|
/**
|
|
551
776
|
* A Save As the worker reported (`BlenderRuntimeOptions.documentMoved`) that the open Model tab
|
|
552
777
|
* has not followed yet. Taken by the {@link blenderExecute} whose script moved it, once that
|
|
@@ -554,6 +779,9 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
|
|
|
554
779
|
*/
|
|
555
780
|
let unfollowedMove: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
|
|
556
781
|
|
|
782
|
+
/** A followed Save As waiting for the table to list its new file (`followMovedDocument`); calling it stops waiting. */
|
|
783
|
+
let awaitingListing: (() => void) | null = null;
|
|
784
|
+
|
|
557
785
|
/**
|
|
558
786
|
* THE MODEL TAB FOLLOWS BLENDER'S SAVE AS. The runtime already holds the new file and its saves
|
|
559
787
|
* go there (`session.py::follow_open_file`); what is left is the tab. Until it moves, the editor
|
|
@@ -563,13 +791,17 @@ let unfollowedMove: { readonly owner: BlenderRuntime; readonly from: string; rea
|
|
|
563
791
|
* its binding. Re-activating the old tab later opens the old file the ordinary way.
|
|
564
792
|
*
|
|
565
793
|
* Only a tab that was showing the old file (or the standing Model of a project that had no
|
|
566
|
-
* `.blend`) is moved; a person who has since switched elsewhere is not pulled back. A
|
|
567
|
-
*
|
|
794
|
+
* `.blend`) is moved; a person who has since switched elsewhere is not pulled back. A file the
|
|
795
|
+
* project's finders list may not be in the project folder yet: a script's call through the
|
|
796
|
+
* project's Blender MCP answers before its Mirror lands the files the script saved, so the tab
|
|
797
|
+
* follows when the table first lists it. A new file outside every finder's `include` is said, not
|
|
798
|
+
* guessed at.
|
|
568
799
|
*/
|
|
569
800
|
async function followMovedDocument(owner: BlenderRuntime): Promise<void> {
|
|
570
801
|
const move = unfollowedMove;
|
|
571
802
|
if (!move || move.owner !== owner) return;
|
|
572
803
|
unfollowedMove = null;
|
|
804
|
+
awaitingListing?.();
|
|
573
805
|
const host = editorHost();
|
|
574
806
|
// The tab showing Blender: the file Blender left, or, after an earlier Save As to an unlisted file, the tab
|
|
575
807
|
// that one left.
|
|
@@ -580,35 +812,56 @@ async function followMovedDocument(owner: BlenderRuntime): Promise<void> {
|
|
|
580
812
|
const shown = shownModelBlend();
|
|
581
813
|
return shown === shownFrom || (shown === undefined && host.documents.activeId() === BLENDER_RUNTIME_DOCUMENT_ID);
|
|
582
814
|
};
|
|
815
|
+
const listing = (table: Awaited<ReturnType<EditorHostProject['documentTable']>>) =>
|
|
816
|
+
table.entries.find(candidate => candidate.kind === 'model' && candidate.source?.path === move.document);
|
|
817
|
+
/** A tab that could not move keeps working on the file Blender holds, as for an unlisted file. */
|
|
818
|
+
const keepShowing = () => {
|
|
819
|
+
if (stillShowingOld()) unlistedFollow = { owner, from: shownFrom, document: move.document };
|
|
820
|
+
};
|
|
821
|
+
const follow = async (id: string) => {
|
|
822
|
+
if (await host.workspace.open({ kind: 'document', id })) return;
|
|
823
|
+
keepShowing();
|
|
824
|
+
host.console.warn(`Blender now has ${move.document} open, but its Model document ${id} did not open; the ${shownFrom} tab keeps showing Blender, which edits ${move.document}.`, 'blender-document');
|
|
825
|
+
};
|
|
826
|
+
const failed = (error: unknown) => {
|
|
827
|
+
keepShowing();
|
|
828
|
+
host.console.error(`The Model tab could not follow Blender to ${move.document}: ${error instanceof Error ? error.message : String(error)}`, 'blender-document');
|
|
829
|
+
};
|
|
583
830
|
if (!stillShowingOld()) return;
|
|
584
831
|
try {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (!stillShowingOld()) return;
|
|
590
|
-
unlistedFollow = { owner, from: shownFrom, document: move.document };
|
|
591
|
-
host.console.warn(
|
|
592
|
-
`Blender now has ${move.document} open and the model saves there. No Model document lists that file ` +
|
|
593
|
-
`(the project's model finder does not include its folder), so the ${shownFrom} tab keeps showing ` +
|
|
594
|
-
`Blender, which now edits ${move.document}; ${shownFrom} keeps what it last saved. Save the model ` +
|
|
595
|
-
`under src/models/ to give it a Model tab of its own.`,
|
|
596
|
-
'blender-document',
|
|
597
|
-
);
|
|
832
|
+
const entry = listing(await host.project.documentTable());
|
|
833
|
+
if (!stillShowingOld()) return;
|
|
834
|
+
if (entry) {
|
|
835
|
+
await follow(entry.id);
|
|
598
836
|
return;
|
|
599
837
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
838
|
+
// Meanwhile the tab works on the file Blender holds.
|
|
839
|
+
unlistedFollow = { owner, from: shownFrom, document: move.document };
|
|
840
|
+
if (host.project.finderIncludes(move.document)) {
|
|
841
|
+
const stop = host.project.onDocumentTable((table) => {
|
|
842
|
+
const listed = listing(table);
|
|
843
|
+
if (!listed) return;
|
|
844
|
+
release();
|
|
845
|
+
if (!stillShowingOld()) return;
|
|
846
|
+
unlistedFollow = null;
|
|
847
|
+
follow(listed.id).catch(failed);
|
|
848
|
+
});
|
|
849
|
+
const release = () => {
|
|
850
|
+
stop();
|
|
851
|
+
if (awaitingListing === release) awaitingListing = null;
|
|
852
|
+
};
|
|
853
|
+
awaitingListing = release;
|
|
854
|
+
return;
|
|
604
855
|
}
|
|
856
|
+
host.console.warn(
|
|
857
|
+
`Blender now has ${move.document} open and the model saves there. No Model document lists that file ` +
|
|
858
|
+
`(no document finder in the project's adapter includes it), so the ${shownFrom} tab keeps showing ` +
|
|
859
|
+
`Blender, which now edits ${move.document}; ${shownFrom} keeps what it last saved. Save the model ` +
|
|
860
|
+
`where the adapter's finder lists models to give it a Model tab of its own.`,
|
|
861
|
+
'blender-document',
|
|
862
|
+
);
|
|
605
863
|
} catch (error) {
|
|
606
|
-
|
|
607
|
-
host.console.error(`The Model tab could not follow Blender to ${move.document}: ${error instanceof Error ? error.message : String(error)}`, 'blender-document');
|
|
608
|
-
}
|
|
609
|
-
/** A tab that could not move keeps working on the file Blender holds, as for an unlisted file. */
|
|
610
|
-
function keepShowing(): void {
|
|
611
|
-
if (stillShowingOld()) unlistedFollow = { owner, from: shownFrom, document: move!.document };
|
|
864
|
+
failed(error);
|
|
612
865
|
}
|
|
613
866
|
}
|
|
614
867
|
|
|
@@ -648,26 +901,19 @@ export async function blenderOutliner(
|
|
|
648
901
|
/** THE NODE-TREE DOOR, in-page — one material's shader node tree, which the
|
|
649
902
|
* Shading workspace's node view draws (`../contributions/blender-node-*`).
|
|
650
903
|
* Same rule as the doors above: it never starts the engine. */
|
|
651
|
-
export async function blenderNodeTree(
|
|
652
|
-
path?: string;
|
|
653
|
-
material?: string;
|
|
654
|
-
}): Promise<BlenderNodeTree | null> {
|
|
904
|
+
export async function blenderNodeTree(): Promise<BlenderNodeTree | null> {
|
|
655
905
|
if (!blenderSessionStarted()) return null;
|
|
656
|
-
return blenderRuntime().nodeTree(
|
|
906
|
+
return blenderRuntime().nodeTree();
|
|
657
907
|
}
|
|
658
908
|
|
|
659
909
|
/** THE UV DOOR, in-page — one mesh's UV layout, which the UV Editing
|
|
660
910
|
* workspace's view draws (`../contributions/blender-uv-*`). Same rule as the
|
|
661
911
|
* doors above: it never starts the engine. */
|
|
662
|
-
export async function blenderUvLayout(
|
|
663
|
-
object?: string;
|
|
664
|
-
uvLayer?: string;
|
|
665
|
-
}): Promise<BlenderUvLayout | null> {
|
|
912
|
+
export async function blenderUvLayout(): Promise<BlenderUvLayout | null> {
|
|
666
913
|
if (!blenderSessionStarted()) return null;
|
|
667
|
-
return blenderRuntime().uvLayout(
|
|
914
|
+
return blenderRuntime().uvLayout();
|
|
668
915
|
}
|
|
669
916
|
|
|
670
|
-
|
|
671
917
|
/** THE CLIP DOOR, in-page — one action as three.js keyframe tracks plus the
|
|
672
918
|
* scene's frame range and the Timeline's summary columns. Called when the
|
|
673
919
|
* action's own revision moves, never per played frame. */
|
|
@@ -683,6 +929,30 @@ export async function blenderActionClip(options?: {
|
|
|
683
929
|
return blenderRuntime().actionClip(options);
|
|
684
930
|
}
|
|
685
931
|
|
|
932
|
+
/** THE DOCUMENT AS A COMPONENT OR A LEVEL, by Blender's own glTF exporter (`session.py`'s
|
|
933
|
+
* `gltf-export`, RFC 0007), and the bytes of a file it wrote in the module filesystem, for the
|
|
934
|
+
* caller to carry into the project. Neither starts the engine. */
|
|
935
|
+
export async function blenderGltfExport(options: { readonly kind: GltfExportKind; readonly force?: boolean; readonly previous?: GltfExportPrevious; readonly blendSha256?: string; readonly luts?: Readonly<Record<string, string>> }): Promise<GltfExportAnswer | null> {
|
|
936
|
+
if (!blenderSessionStarted()) return null;
|
|
937
|
+
return blenderRuntime().gltfExport(options);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Present the document once now (after a game over the standard artifact ends: `blender-runtime.document.tsx`). */
|
|
941
|
+
export async function blenderPresentDocument(): Promise<void> {
|
|
942
|
+
if (!blenderSessionStarted()) return;
|
|
943
|
+
await blenderRuntime().present();
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export async function blenderEngineFile(path: string): Promise<Uint8Array> {
|
|
947
|
+
return blenderRuntime().readFile(path);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Read the scene animation for the Timeline preview and glTF export (`session.py`'s
|
|
951
|
+
* `rna_scene_movie`). Returns null without starting an absent engine session. */
|
|
952
|
+
export async function blenderSceneMovie(): Promise<BlenderSceneMovie | null> {
|
|
953
|
+
if (!blenderSessionStarted()) return null;
|
|
954
|
+
return blenderRuntime().sceneMovie();
|
|
955
|
+
}
|
|
686
956
|
|
|
687
957
|
export async function blenderOutlinerSet(
|
|
688
958
|
path: string,
|
|
@@ -695,46 +965,11 @@ export async function blenderOutlinerSet(
|
|
|
695
965
|
return written;
|
|
696
966
|
}
|
|
697
967
|
|
|
698
|
-
/**
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
* `blender-node-view` verb below. One table, two doors — the shape
|
|
704
|
-
* `key-actions.ts`'s action table already has, and the reason the next
|
|
705
|
-
* three read-only editors (UV Editing, Animation, Texture Paint) add NO session
|
|
706
|
-
* verb of their own: they register here instead.
|
|
707
|
-
*
|
|
708
|
-
* `state` reads it; `look` follows a node with the N-panel (`node`, null to look
|
|
709
|
-
* at none); `view-all` is Blender's Home; `zoom` takes tree units per CSS px;
|
|
710
|
-
* `pan` takes the tree-space point at the view's centre (`cx`/`cy`). Every
|
|
711
|
-
* EDITING gesture is present and REFUSED by name with the same sentence the
|
|
712
|
-
* pointer handlers give, so the read-only ruling is provable from the product
|
|
713
|
-
* rather than merely implemented.
|
|
714
|
-
*/
|
|
715
|
-
export const NODE_VIEW_VERBS: ViewVerbContribution = {
|
|
716
|
-
view: 'blender-node-view',
|
|
717
|
-
title: 'Node Editor',
|
|
718
|
-
verbs: (
|
|
719
|
-
[
|
|
720
|
-
['state', undefined],
|
|
721
|
-
['look', undefined],
|
|
722
|
-
['view-all', 'Node Editor: Frame All'],
|
|
723
|
-
['zoom', undefined],
|
|
724
|
-
['pan', undefined],
|
|
725
|
-
['move', undefined],
|
|
726
|
-
['link', undefined],
|
|
727
|
-
['set-value', undefined],
|
|
728
|
-
['use-nodes', undefined],
|
|
729
|
-
['collapse-panel', undefined],
|
|
730
|
-
] as const
|
|
731
|
-
).map(([id, title]) => ({
|
|
732
|
-
id,
|
|
733
|
-
...(title ? { title } : {}),
|
|
734
|
-
run: (args?: Record<string, unknown>) =>
|
|
735
|
-
driveNodeView({ type: 'blender-node-view', ...args, action: id }),
|
|
736
|
-
})),
|
|
737
|
-
};
|
|
968
|
+
/** Node-view actions share driveNodeView between page commands and the session relay. State reads, focus, framing, zoom and pan update inspection state; editing gestures refuse by name. */
|
|
969
|
+
export const NODE_VIEW_VERBS: readonly ViewVerb[] = (['state', 'look', 'view-all', 'zoom', 'pan', 'move', 'link', 'set-value', 'use-nodes', 'collapse-panel'] as const).map((id) => ({
|
|
970
|
+
id,
|
|
971
|
+
run: (args?: Record<string, unknown>) => driveNodeView({ type: 'blender-node-view', ...args, action: id }),
|
|
972
|
+
}));
|
|
738
973
|
|
|
739
974
|
/**
|
|
740
975
|
* `blender-node-view` — the node editor's actions and its state, as one verb.
|
|
@@ -758,10 +993,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
|
|
|
758
993
|
break;
|
|
759
994
|
case 'view-all':
|
|
760
995
|
requestNodeViewAll();
|
|
761
|
-
//
|
|
762
|
-
// Measured live 2026-09-19: without this, a refusal outlived the gesture
|
|
763
|
-
// that raised it and the status line still carried it three actions
|
|
764
|
-
// later.
|
|
996
|
+
// A successful framing action clears the prior refusal.
|
|
765
997
|
setNodeViewState({ refusal: null });
|
|
766
998
|
break;
|
|
767
999
|
case 'zoom': {
|
|
@@ -781,9 +1013,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
|
|
|
781
1013
|
});
|
|
782
1014
|
break;
|
|
783
1015
|
}
|
|
784
|
-
//
|
|
785
|
-
// refusals the pointer handlers raise, reachable from the session so the
|
|
786
|
-
// ruling is provable rather than merely implemented.
|
|
1016
|
+
// Expose the same named editing refusals through commands and pointer handlers.
|
|
787
1017
|
case 'move':
|
|
788
1018
|
refuseNodeViewGesture(
|
|
789
1019
|
`Moving "${String(cmd['node'] ?? 'a node')}" writes Node.location — editing parity is not the program.`,
|
|
@@ -825,7 +1055,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
|
|
|
825
1055
|
interface RuntimeView {
|
|
826
1056
|
readonly root: THREE.Group;
|
|
827
1057
|
stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void;
|
|
828
|
-
applyFrame(frame: unknown):
|
|
1058
|
+
applyFrame(frame: unknown): ReturnType<BlenderRuntimeView['applyFrame']>;
|
|
829
1059
|
snapshot(): ReturnType<BlenderRuntimeView['snapshot']>;
|
|
830
1060
|
drawStatistics(): ReturnType<BlenderRuntimeView['drawStatistics']>;
|
|
831
1061
|
setDrawBatching(enabled: boolean): void;
|
|
@@ -873,19 +1103,11 @@ function terminateBlenderRuntime(): void {
|
|
|
873
1103
|
captureLifetime = null;
|
|
874
1104
|
runtime?.terminate();
|
|
875
1105
|
runtime = null;
|
|
876
|
-
|
|
1106
|
+
clearModelDocumentSaving();
|
|
877
1107
|
} finally { end(); }
|
|
878
1108
|
}
|
|
879
1109
|
|
|
880
|
-
//
|
|
881
|
-
// `tab-close` calls `window.close()`, which Chrome refuses for a tab a person
|
|
882
|
-
// opened, and falls back to a "Session ended" notice -- a repaint of the
|
|
883
|
-
// document that terminates nothing. The Blender worker kept its whole engine
|
|
884
|
-
// resident behind that notice: measured 2026-09-17, two orphaned tabs from
|
|
885
|
-
// two editor restarts held 13 GB and 7 GB between them and put the box into a
|
|
886
|
-
// swap storm (26 GB of swap, kernel_task at 245%). `session.onEnded` is the
|
|
887
|
-
// host's one signal for every session end, graceful or not, so the worker is
|
|
888
|
-
// terminated there.
|
|
1110
|
+
// Terminate the Blender worker on session.onEnded, including when the browser refuses window.close on a person-opened tab.
|
|
889
1111
|
function watchSessionEnd(): void {
|
|
890
1112
|
if (watchingSessionEnd) return;
|
|
891
1113
|
watchingSessionEnd = true;
|
|
@@ -895,10 +1117,9 @@ function watchSessionEnd(): void {
|
|
|
895
1117
|
|
|
896
1118
|
async function stopBlenderRuntime(): Promise<void> {
|
|
897
1119
|
const owner = runtime;
|
|
898
|
-
await owner
|
|
1120
|
+
if (owner) await retireRuntime(owner);
|
|
899
1121
|
if (runtime === owner) terminateBlenderRuntime();
|
|
900
1122
|
}
|
|
901
|
-
let lastCapture: CaptureRequest | null = null;
|
|
902
1123
|
|
|
903
1124
|
const isRuntimeView = (value: unknown): value is RuntimeView =>
|
|
904
1125
|
typeof value === 'object' &&
|
|
@@ -913,13 +1134,7 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
|
|
|
913
1134
|
|
|
914
1135
|
async function runtimeView(): Promise<RuntimeView> {
|
|
915
1136
|
const { documents } = editorHost();
|
|
916
|
-
//
|
|
917
|
-
// on the Model document's binding (`presentationDocumentId`), and at startup
|
|
918
|
-
// a present can begin before that pane has bound: reading the id once made
|
|
919
|
-
// it wait the whole window for `document:blender:runtime`, which a
|
|
920
|
-
// file-backed Model never publishes, while its own document published beside
|
|
921
|
-
// it -- the battery's preflight failed exactly so, 18 s after its first
|
|
922
|
-
// command (3 s of boot, then the full wait).
|
|
1137
|
+
// Re-read the Model binding while waiting: a presentation can begin before its pane publishes the final document address.
|
|
923
1138
|
const deadline = Date.now() + 15_000;
|
|
924
1139
|
let id = presentationDocumentId();
|
|
925
1140
|
let publication = 'no context';
|
|
@@ -965,12 +1180,19 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
965
1180
|
const discardStreamedFrame = () => {
|
|
966
1181
|
const staged = streamedFrame;
|
|
967
1182
|
streamedFrame = null;
|
|
1183
|
+
frameStagedAt = null;
|
|
968
1184
|
if (staged) staged.view.stageFrame({ session: staged.session, revision: staged.revision, abort: true });
|
|
969
1185
|
discardRenderFrame();
|
|
970
1186
|
};
|
|
971
1187
|
lifetime.signal.addEventListener('abort', discardStreamedFrame, { once: true });
|
|
972
1188
|
runtime = new BlenderRuntime({
|
|
973
1189
|
work: beginBlenderWork,
|
|
1190
|
+
documentSaving: ({ document, note }) => {
|
|
1191
|
+
if (!lifetime.signal.aborted) setModelDocumentSaveNotice(document, note);
|
|
1192
|
+
},
|
|
1193
|
+
documentDirty: () => {
|
|
1194
|
+
for (const listener of [...modelDirtyListeners]) listener();
|
|
1195
|
+
},
|
|
974
1196
|
history: (entries) => {
|
|
975
1197
|
const owner = runtime;
|
|
976
1198
|
if (!owner) throw new Error('Blender history arrived before its runtime');
|
|
@@ -1013,6 +1235,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1013
1235
|
if (part.evaluation === 'render') {
|
|
1014
1236
|
if (!renderFrame) {
|
|
1015
1237
|
if (part.mesh !== undefined || part.image !== undefined) throw new Error('Render resource arrived before its frame');
|
|
1238
|
+
frameStagedAt = performance.now();
|
|
1016
1239
|
const detached = BlenderRuntimeView.forPhotograph();
|
|
1017
1240
|
view.root.updateWorldMatrix(true, false);
|
|
1018
1241
|
detached.root.matrix.copy(view.root.matrixWorld);
|
|
@@ -1026,6 +1249,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1026
1249
|
if (renderFrame) throw new Error('Viewport frame arrived during render transfer');
|
|
1027
1250
|
if (streamedFrame && streamedFrame.view !== view)
|
|
1028
1251
|
throw new Error('Blender presenter changed during frame transfer');
|
|
1252
|
+
if (!streamedFrame) frameStagedAt = performance.now();
|
|
1029
1253
|
view.stageFrame(part);
|
|
1030
1254
|
streamedFrame = { view, session: part.session, revision: part.revision };
|
|
1031
1255
|
},
|
|
@@ -1060,31 +1284,26 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1060
1284
|
if (rendering && (rendering.source !== view || rendering.sourceFrame !== view.snapshot() || rendering.documentId !== documentId))
|
|
1061
1285
|
throw new Error('Blender capture source changed before render presentation');
|
|
1062
1286
|
const presenter = rendering?.view ?? view;
|
|
1063
|
-
//
|
|
1064
|
-
// beside whatever this present produced. The session only has a RECORD of
|
|
1065
|
-
// what it sent; this view is the authority on what it actually holds, and
|
|
1066
|
-
// the two come apart whenever the Model document is rebuilt under a
|
|
1067
|
-
// still-running worker (`blender-runtime-view.ts::applyFrame` says how).
|
|
1068
|
-
// A document that answers `applyFrame` without one reports no `held` at
|
|
1069
|
-
// all, rather than a wrong `null`.
|
|
1287
|
+
// Report the presenter’s holding before applying this frame.
|
|
1070
1288
|
const endApply = beginBlenderWork('applying frame to Model');
|
|
1289
|
+
const applyAt = performance.now();
|
|
1071
1290
|
const applied = (() => {
|
|
1072
|
-
try { return presenter.applyFrame(frame)
|
|
1291
|
+
try { return presenter.applyFrame(frame); }
|
|
1073
1292
|
finally { endApply(); }
|
|
1074
1293
|
})();
|
|
1294
|
+
const appliedAt = performance.now();
|
|
1295
|
+
const stagedAt = frameStagedAt;
|
|
1296
|
+
frameStagedAt = null;
|
|
1075
1297
|
streamedFrame = null;
|
|
1076
|
-
const reports = typeof applied === 'object' && applied !== null && 'held' in applied;
|
|
1077
|
-
const held = reports ? (applied.held as { session: string; revision: number } | null) : null;
|
|
1078
1298
|
const answer = (capture: unknown): PresentAnswer => ({
|
|
1079
1299
|
...(capture === undefined ? {} : { capture }),
|
|
1080
|
-
|
|
1300
|
+
held: applied.held,
|
|
1081
1301
|
});
|
|
1082
1302
|
// THE RECORD OF WHAT WAS SUBMITTED, kept only once the frame was taken:
|
|
1083
1303
|
// a refused frame is not displayed and must not be described as if it
|
|
1084
1304
|
// were.
|
|
1085
1305
|
presenter.recordPresentation(description);
|
|
1086
1306
|
if (!capture) return answer(undefined);
|
|
1087
|
-
lastCapture = capture;
|
|
1088
1307
|
if (!capture.render) return answer(undefined);
|
|
1089
1308
|
const presented = view.snapshot();
|
|
1090
1309
|
// A COMPOSITED FRAME IS NOT PHOTOGRAPHED. The compositor already
|
|
@@ -1110,14 +1329,10 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1110
1329
|
// through THAT matrix -- copied into the detached snapshot, never written out
|
|
1111
1330
|
// here as a quarter turn -- and a fresh camera is placed on it.
|
|
1112
1331
|
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
// produced a level side view. Every hero image the battery ever graded
|
|
1118
|
-
// came from a misplaced camera. The old path also moved the VIEWPORT onto
|
|
1119
|
-
// the render camera and moved it back; a render that never touches the
|
|
1120
|
-
// viewport needs no such dance, so it is gone rather than made safe.
|
|
1332
|
+
// Handing the raw Blender numbers to `presentEditorView` would read them
|
|
1333
|
+
// as three.js world space and photograph the model from a misplaced
|
|
1334
|
+
// camera, and a render that never touches the viewport needs no move of
|
|
1335
|
+
// the VIEWPORT onto the render camera and back.
|
|
1121
1336
|
const { render, position, target, up } = capture;
|
|
1122
1337
|
if (!position || !target || !up)
|
|
1123
1338
|
throw new Error(
|
|
@@ -1151,22 +1366,13 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1151
1366
|
renderCamera.position.copy(eye);
|
|
1152
1367
|
renderCamera.lookAt(focus);
|
|
1153
1368
|
renderCamera.updateMatrixWorld(true);
|
|
1154
|
-
//
|
|
1155
|
-
// it equals what it sent, with no tolerance: the model root's matrix is an
|
|
1156
|
-
// exact signed axis permutation, so a vector through it and its inverse is
|
|
1157
|
-
// bit-identical. A mismatch means the conversion above is wrong, and the
|
|
1158
|
-
// whole point of this instrument is that a misplaced render camera is
|
|
1159
|
-
// otherwise invisible -- photograph-vs-Cycles is a declared difference, so
|
|
1160
|
-
// the battery's image rows hid it for the entire life of this lane.
|
|
1369
|
+
// Transform the photograph’s pose back through the exact signed axis permutation. Python compares it with the submitted pose and rejects any mismatch.
|
|
1161
1370
|
const photographedFrom = {
|
|
1162
1371
|
position: eye.clone().applyMatrix4(toBlender).toArray(),
|
|
1163
1372
|
target: focus.clone().applyMatrix4(toBlender).toArray(),
|
|
1164
1373
|
up: upward.clone().applyMatrix3(blenderBasis).toArray(),
|
|
1165
1374
|
};
|
|
1166
|
-
//
|
|
1167
|
-
// two poses. The assertion on the other side THROWS on a mismatch, and
|
|
1168
|
-
// an exception carries the defect out of reach of a harness grading the
|
|
1169
|
-
// run afterwards; both poses have to survive it.
|
|
1375
|
+
// Retain both poses before capture and Python validation, including a refused photograph.
|
|
1170
1376
|
view.recordPhotograph({
|
|
1171
1377
|
sent: { position, target, up },
|
|
1172
1378
|
photographed: photographedFrom,
|
|
@@ -1177,7 +1383,15 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1177
1383
|
orthographic: render.orthographic,
|
|
1178
1384
|
},
|
|
1179
1385
|
});
|
|
1386
|
+
const photoAt = performance.now();
|
|
1180
1387
|
const display = await photographSnapshot(scene, renderCamera, snapshot, render, assertBinding);
|
|
1388
|
+
// Queue each photograph directly for the movie encoder.
|
|
1389
|
+
if (film) film.photographs.push(display.base64);
|
|
1390
|
+
// (a film's frames are viewport photographs, `rendering` null: they are timed too)
|
|
1391
|
+
if (rendering || film) {
|
|
1392
|
+
renderTimings.push({ stageMs: stagedAt === null ? 0 : applyAt - stagedAt, applyMs: appliedAt - applyAt, photoMs: performance.now() - photoAt });
|
|
1393
|
+
if (renderTimings.length > 64) renderTimings.shift();
|
|
1394
|
+
}
|
|
1181
1395
|
return answer({ ...display, camera: photographedFrom });
|
|
1182
1396
|
} finally {
|
|
1183
1397
|
photographing = false;
|
|
@@ -1220,25 +1434,7 @@ export function blenderRuntime(): BlenderRuntime {
|
|
|
1220
1434
|
return runtime;
|
|
1221
1435
|
}
|
|
1222
1436
|
|
|
1223
|
-
/** The
|
|
1224
|
-
export function lastBlenderCapture(): CaptureRequest | null {
|
|
1225
|
-
return lastCapture;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/**
|
|
1229
|
-
* THE SESSION'S DOCUMENT when `blender-start` does not name one.
|
|
1230
|
-
*
|
|
1231
|
-
* Persistence is ON by default, because the job this lane exists to do is to
|
|
1232
|
-
* not lose what a script modelled: before this, closing the tab lost it, with
|
|
1233
|
-
* no `.blend` anywhere. `models/` rather than `public/` is what says a
|
|
1234
|
-
* document is the SOURCE a shipped artifact is exported from, not the artifact
|
|
1235
|
-
* — which is also why it carries no provenance record (`volter blender-mcp`'s
|
|
1236
|
-
* Mirror owns that line, for `public/`).
|
|
1237
|
-
*
|
|
1238
|
-
* It is the fallback only. A project that declares the `model` finder lists
|
|
1239
|
-
* its OWN `.blend` files, and the document opened for one of those binds the
|
|
1240
|
-
* session to it ({@link openModelDocumentBlend}).
|
|
1241
|
-
*/
|
|
1437
|
+
/** The default session document persists under models/ as source. A project’s declared Model file takes precedence. Public exported artifacts are recorded by the MCP mirror. */
|
|
1242
1438
|
const DEFAULT_BLENDER_DOCUMENT = 'models/model.blend';
|
|
1243
1439
|
|
|
1244
1440
|
const string = (cmd: Record<string, unknown>, key: string): string => {
|
|
@@ -1352,16 +1548,16 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1352
1548
|
// After a script's Save As to a file no Model lists, the session this tab
|
|
1353
1549
|
// shows holds that file (`unlistedFollow`), and a start naming none
|
|
1354
1550
|
// asks for it, not for the file the tab was opened on.
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1551
|
+
// The shown file is read as `modelDocumentConflict` reads it (active tab first): the bound pane lags a
|
|
1552
|
+
// tab that just followed a Save As (`../src/start-document.ts`).
|
|
1553
|
+
const document = startDocument({
|
|
1554
|
+
requested: requestedDocument,
|
|
1555
|
+
shown: shownModelBlend(),
|
|
1556
|
+
follow: unlistedFollow !== null && unlistedFollow.owner === session ? unlistedFollow : null,
|
|
1557
|
+
fallback: DEFAULT_BLENDER_DOCUMENT,
|
|
1558
|
+
});
|
|
1363
1559
|
try {
|
|
1364
|
-
return { ok: true, data: { ...(await session
|
|
1560
|
+
return { ok: true, data: { ...(await startSession(session, project!, document)) } };
|
|
1365
1561
|
} catch (error) {
|
|
1366
1562
|
// As the document's own open does: a session whose start failed is discarded, so the
|
|
1367
1563
|
// next `blender-start` makes a new one instead of awaiting the same failure.
|
|
@@ -1370,16 +1566,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1370
1566
|
}
|
|
1371
1567
|
}
|
|
1372
1568
|
case 'blender-execute': {
|
|
1373
|
-
//
|
|
1374
|
-
// rule `blender-rna-set` below follows, and for the same reason: the
|
|
1375
|
-
// RNA version is minted at the door (ruling 3, 2026-09-19), and
|
|
1376
|
-
// ARBITRARY bpy can change anything RNA answers, so a script that
|
|
1377
|
-
// reached the engine around it would leave every view drawing the tree
|
|
1378
|
-
// it had. One path for a `cyclotron eval`, an MCP call and a panel's own
|
|
1379
|
-
// operator.
|
|
1380
|
-
// THE DOOR'S TEXT, verbatim — `execute_blender_code`'s MCP contract is
|
|
1381
|
-
// that one string, so the wire keeps answering it while the in-page
|
|
1382
|
-
// callers read the parsed halves beside it.
|
|
1569
|
+
// Use the in-page script door so every bpy write bumps the shared RNA version. Wire callers receive the original MCP text.
|
|
1383
1570
|
const answer = await blenderExecute(string(cmd, 'code'));
|
|
1384
1571
|
return { ok: true, data: { result: answer.text } };
|
|
1385
1572
|
}
|
|
@@ -1404,10 +1591,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1404
1591
|
}
|
|
1405
1592
|
case 'blender-rna-set': {
|
|
1406
1593
|
const index = typeof cmd['index'] === 'number' ? cmd['index'] : undefined;
|
|
1407
|
-
//
|
|
1408
|
-
// where the RNA version is minted (ruling 3, 2026-09-19), and a write
|
|
1409
|
-
// that reached the engine around it would leave every view drawing the
|
|
1410
|
-
// tree it had — which is exactly the defect the version exists for.
|
|
1594
|
+
// Use the in-page RNA write door so every write bumps the shared version.
|
|
1411
1595
|
return {
|
|
1412
1596
|
ok: true,
|
|
1413
1597
|
data: {
|
|
@@ -1473,30 +1657,11 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1473
1657
|
// is a projection of the tree the door already answered, so its actions
|
|
1474
1658
|
// are pure state (`src/node-view-state.ts`).
|
|
1475
1659
|
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
// answered with.
|
|
1482
|
-
case 'blender-node-view': {
|
|
1483
|
-
const action = typeof cmd['action'] === 'string' ? cmd['action'] : 'state';
|
|
1484
|
-
// An action the TABLE does not carry is still the VIEW's refusal, not the
|
|
1485
|
-
// registry's: I5's proof is that an unknown action names the verb's whole
|
|
1486
|
-
// vocabulary IN THE VIEW's own frame warning, and a thrown registry message
|
|
1487
|
-
// would move that refusal somewhere the view does not draw. So the registry
|
|
1488
|
-
// answers when it can and `driveNodeView`'s own default answers when it
|
|
1489
|
-
// cannot — one implementation either way.
|
|
1490
|
-
const known = NODE_VIEW_VERBS.verbs.some((verb) => verb.id === action);
|
|
1491
|
-
return {
|
|
1492
|
-
ok: true,
|
|
1493
|
-
data: {
|
|
1494
|
-
result: known
|
|
1495
|
-
? (invokeViewVerb('blender-node-view', action, cmd) as NodeViewState)
|
|
1496
|
-
: driveNodeView(cmd),
|
|
1497
|
-
},
|
|
1498
|
-
};
|
|
1499
|
-
}
|
|
1660
|
+
// The same table the `volter.blender-node-view.<verb>` commands run
|
|
1661
|
+
// (`NODE_VIEW_VERBS`): every action is `driveNodeView`'s, and an action it
|
|
1662
|
+
// does not carry is refused by its own default, naming the vocabulary.
|
|
1663
|
+
case 'blender-node-view':
|
|
1664
|
+
return { ok: true, data: { result: driveNodeView(cmd) } };
|
|
1500
1665
|
case 'blender-outliner-set':
|
|
1501
1666
|
return {
|
|
1502
1667
|
ok: true,
|
|
@@ -1528,10 +1693,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1528
1693
|
return { ok: true, data: { ...preview } };
|
|
1529
1694
|
}
|
|
1530
1695
|
case 'blender-read-file': {
|
|
1531
|
-
//
|
|
1532
|
-
// `/__editor/blender-file` in `server/routes/relay.ts` for the
|
|
1533
|
-
// measurement that moved them. The caller mints the transfer id, so
|
|
1534
|
-
// the page never names anything on the host's disk.
|
|
1696
|
+
// Transfer bytes in the HTTP body under a caller-minted id; return only the size in the command answer.
|
|
1535
1697
|
const bytes = await session.readFile(string(cmd, 'path'));
|
|
1536
1698
|
const transfer = string(cmd, 'transferId');
|
|
1537
1699
|
const posted = await fetch(`/__editor/blender-file?id=${encodeURIComponent(transfer)}`, {
|
|
@@ -1543,9 +1705,32 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1543
1705
|
return { ok: false, error: `Transfer ${transfer} was refused: ${posted.status}` };
|
|
1544
1706
|
return { ok: true, data: { bytes: bytes.length } };
|
|
1545
1707
|
}
|
|
1708
|
+
// A FILM, RENDERED (`cyclotron render-movie`, a film's Blender shots): started here, answering at once, and
|
|
1709
|
+
// followed until it ends (`renderBlenderMovie`).
|
|
1710
|
+
case 'blender-render-movie': {
|
|
1711
|
+
const optional = (key: string): string | number | undefined => {
|
|
1712
|
+
const value = cmd[key];
|
|
1713
|
+
if (value === undefined || typeof value === 'number' || typeof value === 'string') return value;
|
|
1714
|
+
throw new Error(`${key} is a frame number or a marker name.`);
|
|
1715
|
+
};
|
|
1716
|
+
const width = cmd['width'];
|
|
1717
|
+
const fps = cmd['fps'];
|
|
1718
|
+
const document = cmd['document'];
|
|
1719
|
+
return {
|
|
1720
|
+
ok: true,
|
|
1721
|
+
data: { ...startBlenderMovie({
|
|
1722
|
+
out: string(cmd, 'out'),
|
|
1723
|
+
...(typeof document === 'string' ? { document } : {}),
|
|
1724
|
+
from: optional('from'), to: optional('to'),
|
|
1725
|
+
...(typeof width === 'number' ? { width } : {}),
|
|
1726
|
+
...(typeof fps === 'number' ? { fps } : {}),
|
|
1727
|
+
}) },
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
case 'blender-render-status':
|
|
1731
|
+
return { ok: true, data: { ...(await movieRenders.status(string(cmd, 'out'))) } };
|
|
1546
1732
|
case 'blender-write-file': {
|
|
1547
|
-
const
|
|
1548
|
-
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
1733
|
+
const bytes = bytesFromBase64(string(cmd, 'base64'));
|
|
1549
1734
|
await session.writeFile(string(cmd, 'path'), bytes);
|
|
1550
1735
|
return { ok: true, data: { written: bytes.length } };
|
|
1551
1736
|
}
|
|
@@ -1563,23 +1748,7 @@ export async function handleBlenderCommand(cmd: {
|
|
|
1563
1748
|
entries,
|
|
1564
1749
|
session: document.session,
|
|
1565
1750
|
revision: document.revision,
|
|
1566
|
-
//
|
|
1567
|
-
// exported from a `.blend`, and that path is the one fact that
|
|
1568
|
-
// lets a reader go DOWN by kind later — from a prefab's glTF to
|
|
1569
|
-
// the model document it came from (ARCHITECTURE-CORE §Roots,
|
|
1570
|
-
// "Drilling goes DOWN by kind"). Taken at the instant of the
|
|
1571
|
-
// listing, for the same reason the session identity is.
|
|
1572
|
-
//
|
|
1573
|
-
// IT IS ASKED OF PYTHON, NOT OF `boundModel`, and that distinction
|
|
1574
|
-
// is a measurement rather than a preference. `boundModel` is the
|
|
1575
|
-
// document the TAB opened; a bake script opens its own file with
|
|
1576
|
-
// `wm.open_mainfile`, which retargets `bpy.data.filepath` at once,
|
|
1577
|
-
// the tab only after the call (`followMovedDocument`), and never
|
|
1578
|
-
// for a file outside the project. Measured 2026-09-19: reading `boundModel` recorded
|
|
1579
|
-
// the SAME `.blend` as the input of all three of first-person's
|
|
1580
|
-
// characters — whichever one the tab happened to show — which is
|
|
1581
|
-
// worse than no record, because it is a confident wrong answer.
|
|
1582
|
-
// `bpy.data.filepath` is what the export actually came out of.
|
|
1751
|
+
// Read bpy.data.filepath at listing time as the exported files’ input. A script may have opened a different file before the tab follows that move.
|
|
1583
1752
|
...sessionDocumentPath(document.filepath),
|
|
1584
1753
|
},
|
|
1585
1754
|
};
|