@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.
Files changed (93) hide show
  1. package/LICENSE +18 -9
  2. package/README.md +5 -5
  3. package/contributions/blender-animation-editors.tsx +17 -69
  4. package/contributions/blender-display-settings.ts +36 -0
  5. package/contributions/blender-export-gltf.ts +228 -0
  6. package/contributions/blender-export.verbs.ts +39 -0
  7. package/contributions/blender-gltf-export.verbs.ts +312 -0
  8. package/contributions/blender-header-menus.tsx +37 -133
  9. package/contributions/blender-icon-trace.mjs +8 -98
  10. package/contributions/blender-icons.source.mjs +96 -860
  11. package/contributions/blender-mixer-pose.ts +369 -0
  12. package/contributions/blender-model-opening.tsx +3 -7
  13. package/contributions/blender-node-editor.document.tsx +28 -180
  14. package/contributions/blender-node-geometry.ts +44 -230
  15. package/contributions/blender-node-panels.source.mjs +7 -121
  16. package/contributions/blender-outliner-authoring.ts +61 -415
  17. package/contributions/blender-outliner-model.ts +15 -112
  18. package/contributions/blender-play-area.tsx +100 -0
  19. package/contributions/blender-playback-movie.ts +409 -0
  20. package/contributions/blender-pose.ts +18 -368
  21. package/contributions/blender-properties-context.ts +1 -1
  22. package/contributions/blender-properties-model.ts +15 -90
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +15 -147
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +217 -339
  28. package/contributions/blender-scene-mixer.ts +51 -0
  29. package/contributions/blender-screenshot.service.ts +28 -0
  30. package/contributions/blender-timeline-geometry.ts +16 -136
  31. package/contributions/blender-timeline.document.tsx +142 -332
  32. package/contributions/blender-uv-editor.document.tsx +48 -112
  33. package/contributions/blender-uv-geometry.ts +14 -179
  34. package/contributions/blender-version.status.tsx +43 -23
  35. package/contributions/blender.environment.ts +2 -2
  36. package/contributions/blender.icons.json +0 -9
  37. package/contributions/blender.keymap.ts +9 -51
  38. package/contributions/blender.node-panels.json +4 -13
  39. package/contributions/blender.page.ts +63 -0
  40. package/contributions/blender.status.tsx +9 -145
  41. package/contributions/blender.style.ts +25 -152
  42. package/contributions/blender.verbs.ts +52 -0
  43. package/contributions/model.layout.ts +2 -34
  44. package/contributions/models.finder.ts +12 -32
  45. package/contributions/properties-bone-constraints.inspector.tsx +1 -16
  46. package/contributions/properties-bone.inspector.tsx +2 -31
  47. package/contributions/properties-collection.inspector.tsx +1 -34
  48. package/contributions/properties-constraints.inspector.tsx +1 -21
  49. package/contributions/properties-data.inspector.tsx +2 -39
  50. package/contributions/properties-material.inspector.tsx +2 -33
  51. package/contributions/properties-modifiers.inspector.tsx +2 -30
  52. package/contributions/properties-object.inspector.tsx +2 -44
  53. package/contributions/properties-output.inspector.tsx +1 -27
  54. package/contributions/properties-particles.inspector.tsx +2 -46
  55. package/contributions/properties-physics.inspector.tsx +2 -41
  56. package/contributions/properties-render.inspector.tsx +1 -45
  57. package/contributions/properties-scene.inspector.tsx +1 -35
  58. package/contributions/properties-texture.inspector.tsx +1 -49
  59. package/contributions/properties-view-layer.inspector.tsx +1 -29
  60. package/contributions/properties-world.inspector.tsx +2 -39
  61. package/contributions/shading.layout.ts +3 -78
  62. package/contributions/uv-editing.layout.ts +3 -70
  63. package/dist-node/serving.mjs +13 -30
  64. package/host/blender-runtime-host.ts +541 -372
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +49 -93
  67. package/package.json +1209 -46
  68. package/producticons/blender-icons.otf +0 -0
  69. package/producticons/blender-product-icon-theme.json +129 -0
  70. package/serving/blender-routes.ts +9 -43
  71. package/serving/blender-wasm-artifact.ts +29 -166
  72. package/serving/index.ts +1 -1
  73. package/src/api.ts +9 -0
  74. package/src/layouts.tsx +1 -17
  75. package/src/looks.ts +1 -12
  76. package/src/model-document-saving.ts +25 -0
  77. package/src/model-play-extension.ts +173 -0
  78. package/src/node-view-state.ts +3 -25
  79. package/src/play-mode.ts +64 -38
  80. package/src/presentation.ts +6 -18
  81. package/src/regions.ts +3 -43
  82. package/src/start-document.ts +15 -0
  83. package/src/timeline-view-state.ts +4 -39
  84. package/src/uv-view-state.ts +4 -20
  85. package/themes/blender.json +299 -0
  86. package/view/blender-view-routes.ts +1 -1
  87. package/contributions/blender-game-panel.tsx +0 -809
  88. package/contributions/blender-palette.source.mjs +0 -350
  89. package/contributions/blender-play-skin.ts +0 -508
  90. package/contributions/blender-screenshot.menu.ts +0 -32
  91. package/contributions/blender.command.ts +0 -109
  92. package/contributions/blender.palette.json +0 -102
  93. 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 { EditorCommandResult } from '@volter/sdk/commands';
47
- import { editorHost } from '@volter/sdk/host';
48
- import { invokeViewVerb, type ViewVerbContribution } from '@volter/sdk/views';
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 binary = atob(base64);
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: an agent told
245
- // just "return to <held>" took it as the Blender door being unusable and built its level by
246
- // another route (2026-10-06, the WSL obby run).
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
- // stop drains accepted work and flushes the OLD worker's bound file. If
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
- const end = beginBlenderWork('saving previous model before opening another');
352
- try { await previous.stop(); } finally { end(); }
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.start(project, binding.blend);
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
- // A session whose start failed keeps that failure and never starts. Discard it, so that
367
- // "Retry opening" makes a new session and a new worker; without this every retry awaited the
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 the opened file before start resolves. Do not
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
- * be the reason a 880 MB Blender boots, so these answer `null` when this tab
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, names?: number): Promise<BlenderRnaView | null> {
546
+ export async function blenderRna(path: string): Promise<BlenderRnaView | null> {
459
547
  if (!blenderSessionStarted()) return null;
460
- return blenderRuntime().rna(path, names);
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 new file
567
- * no Model document lists (outside the project's model finder) is said, not guessed at.
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
- // The table re-reads the project's files, which now include the file this call saved.
586
- const table = await host.project.documentTable();
587
- const entry = table.entries.find(candidate => candidate.kind === 'model' && candidate.source?.path === move.document);
588
- if (!entry) {
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
- if (!stillShowingOld()) return;
601
- if (!await host.workspace.open({ kind: 'document', id: entry.id })) {
602
- keepShowing();
603
- host.console.warn(`Blender now has ${move.document} open, but its Model document ${entry.id} did not open; the ${shownFrom} tab keeps showing Blender, which edits ${move.document}.`, 'blender-document');
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
- keepShowing();
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(options?: {
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(options);
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(options?: {
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(options);
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
- * THE NODE EDITOR'S VERBS, published ONCE and reached two ways (U8's ruling 1,
700
- * 2026-09-19). Under the Code-OSS frame each is a `volter.blender-node-view.<verb>`
701
- * command the bridge dispatches into the view; standalone `cyclotron edit`, which has
702
- * no command service, reaches the SAME table through the session's
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
- // Framing clears a refusal the way every other non-editing action does.
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
- // EVERY EDITING GESTURE, ANSWERED BY NAME. These are the same four
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): 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
- lastCapture = null;
1106
+ clearModelDocumentSaving();
877
1107
  } finally { end(); }
878
1108
  }
879
1109
 
880
- // THE ENGINE DIES WITH THE SESSION, whether or not the tab can. A page told
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?.stop();
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
- // THE ID IS RE-READ WHILE WAITING. Which document a present reaches depends
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
- // WHAT THE PRESENTER HELD BEFORE THIS FRAME, carried back to the session
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) as { held?: unknown } | null | undefined; }
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
- ...(reports ? { held } : {}),
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
- // MEASURED 2026-09-18, and the reason this code exists: handing the raw
1114
- // Blender numbers to `presentEditorView` read them as three.js world
1115
- // space, so a camera at Blender (0, -3, 0.42) looking +Y photographed the
1116
- // model's underside from 3 m below the floor, and a straight-down camera
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
- // THE POSE THIS PHOTOGRAPH USED, BACK IN BLENDER'S FRAME. Python asserts
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
- // RECORDED BEFORE THE PICTURE IS TAKEN, and before Python compares the
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 view the last `blender-screenshot-view` asked the tab to photograph. */
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
- const shown =
1356
- unlistedFollow !== null && unlistedFollow.owner === session && unlistedFollow.from === (boundModel?.blend ?? DEFAULT_BLENDER_DOCUMENT)
1357
- ? unlistedFollow.document
1358
- : boundModel?.blend;
1359
- const document =
1360
- requestedDocument !== undefined
1361
- ? requestedDocument
1362
- : (shown ?? DEFAULT_BLENDER_DOCUMENT);
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.start(project!, document)) } };
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
- // THROUGH THE IN-PAGE DOOR, not `session.execute` beside it — the same
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
- // THROUGH THE IN-PAGE DOOR, not `session.rnaSet` beside it: the door is
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
- // THE SESSION IS THE STANDALONE DOOR ONTO `NODE_VIEW_VERBS`, not a second
1477
- // implementation (U8's ruling 1). Under the Code-OSS frame each verb is a
1478
- // `volter.blender-node-view.<verb>` command; standalone `cyclotron edit` has no
1479
- // command service, so this verb routes the SAME table. `invokeViewVerb`
1480
- // throws the view's own refusal, which is the sentence this door already
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
- // The bytes go out as a BODY, not inside this command's answer -- see
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 binary = atob(string(cmd, 'base64'));
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
- // AND WHAT THE BYTES WERE MADE FROM. A session-written `.glb` is
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
  };