@volter/editor-blender 0.1.150 → 0.1.152

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +26 -56
  13. package/contributions/blender-node-editor.document.tsx +30 -181
  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 +99 -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 +59 -91
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +39 -181
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +170 -618
  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 +145 -333
  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 +45 -31
  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 +64 -0
  40. package/contributions/blender.status.tsx +8 -150
  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 +3 -30
  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 -32
  64. package/host/blender-runtime-host.ts +795 -412
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +52 -95
  67. package/package.json +1221 -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 -48
  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/node-view-state.ts +3 -25
  78. package/src/play-mode.ts +13 -42
  79. package/src/play-tool.ts +44 -0
  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,17 @@
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,
6
+ DocumentNotOpened,
31
7
  type PresentAnswer,
8
+ type SavedDocument,
9
+ type SaveScript,
32
10
  } from '@volter/blender-engine/browser';
33
- import type { RenderRequest } from '@volter/blender-engine/browser/protocol';
11
+ import type { BlenderAssetLibrary, DocumentOpened, GltfExportAnswer, GltfExportKind, GltfExportPrevious, RenderRequest } from '@volter/blender-engine/browser/protocol';
34
12
  import type {
35
13
  BlenderActionClip,
14
+ BlenderSceneMovie,
36
15
  BlenderNodeTree,
37
16
  BlenderOutlinerTree,
38
17
  BlenderOutlinerWrite,
@@ -43,9 +22,10 @@ import type {
43
22
  } from '@volter/blender-engine/browser/rna';
44
23
  import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from '@volter/blender-engine/browser/three/blender-display-transform';
45
24
  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';
25
+ import { commandJobs, type EditorCommandResult } from '@volter/sdk/commands';
26
+ import { editorHost, type EditorHostProject } from '@volter/sdk/host';
27
+ import { waitUntil } from '@volter/sdk/wait-until';
28
+ import type { ViewVerb } from '@volter/sdk/commands';
49
29
  import {
50
30
  captureSceneImage,
51
31
  captureSceneLinear,
@@ -53,6 +33,10 @@ import {
53
33
  type LinearCaptureFrame,
54
34
  } from '@volter/editor-threejs/capture/scene';
55
35
  import {blenderRenderCamera} from '@volter/blender-engine/browser/three/blender-render-camera';
36
+ import { beginMovie, type Movie } from '@volter/editor-movie';
37
+ import { mixSceneSound } from './scene-sound';
38
+ import { clearModelDocumentSaving, setModelDocumentSaveNotice } from '../src/model-document-saving';
39
+ import { startDocument } from '../src/start-document';
56
40
  import * as THREE from 'three';
57
41
  import { blenderEngineSelection, refreshBlenderOutliner } from '../contributions/blender-outliner-model';
58
42
  import {
@@ -65,9 +49,7 @@ import {
65
49
 
66
50
  /** Raw bytes of a base64 payload, as the half-float words a linear frame is. */
67
51
  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);
52
+ const bytes = bytesFromBase64(base64);
71
53
  return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
72
54
  }
73
55
 
@@ -180,21 +162,7 @@ export async function photographSnapshot(
180
162
 
181
163
  export const BLENDER_RUNTIME_DOCUMENT_ID = 'document:blender:runtime';
182
164
 
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
- */
165
+ /** 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
166
  export interface ModelDocumentBinding {
199
167
  readonly documentId: string;
200
168
  readonly entryId: string;
@@ -229,11 +197,7 @@ function shownModelBlend(): string | undefined {
229
197
  : boundModel?.blend;
230
198
  }
231
199
 
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
- */
200
+ /** 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
201
  let unlistedFollow: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
238
202
 
239
203
  function modelDocumentConflict(): string | null {
@@ -241,9 +205,10 @@ function modelDocumentConflict(): string | null {
241
205
  const requested = shownModelBlend();
242
206
  if (!requested || !held || requested === held) return null;
243
207
  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).
208
+ // Say how to get Blender onto the document the editor shows, not only how to go back: "return to <held>"
209
+ // alone reads as the Blender door being unusable.
210
+ // (`session.py`'s `_another_document_shown` reads "but the editor is showing": a first present refused so is a
211
+ // document switch, not a failure of the document's.)
247
212
  // Plain words: people see this in error notices too. Re-opening the document already showing does not
248
213
  // remount a failed open (blender-runtime.document.tsx), so the steps that do are named instead.
249
214
  return `Blender is editing ${held}, but the editor is showing ${requested}, which Blender has not loaded. ` +
@@ -273,6 +238,16 @@ export function modelDocumentMayOpen(binding: ModelDocumentBinding): boolean {
273
238
  return !anotherModel || active === binding.documentId;
274
239
  }
275
240
 
241
+ /** THE `.blend` A MODEL VIEW SHOWS NOW: the runtime's document, once `view` holds that runtime's
242
+ * latest present; null between opening a file and its first present (the view still shows the
243
+ * last one). */
244
+ export function presentedModelBlend(view: { snapshot(): { readonly session: string; readonly revision: number } | null }): string | null {
245
+ const latest = runtime?.presented;
246
+ const shown = view.snapshot();
247
+ if (!runtime || !latest || !shown || shown.session !== latest.session || shown.revision !== latest.revision) return null;
248
+ return runtime.document ?? null;
249
+ }
250
+
276
251
  /** A retained photograph must belong to the file and revision still on screen,
277
252
  * not a retiring pane or the next worker's first frame. */
278
253
  export function modelDocumentOwnsPresentation(
@@ -289,28 +264,331 @@ export function modelDocumentOwnsPresentation(
289
264
  !!presented && !!latest && presented.session === latest.session && presented.revision === latest.revision;
290
265
  }
291
266
 
267
+ /**
268
+ * THE MODEL DOCUMENT'S WORKING COPY — what the workbench calls for the `.blend` the engine holds
269
+ * (the `workingCopy` of `blender-runtime.document.tsx`). The engine is its one writer: a save is
270
+ * the worker's own, over the session's delta route, and the rest reopen the document inside the
271
+ * engine.
272
+ */
273
+ const modelDirtyListeners = new Set<() => void>();
274
+
275
+ export function subscribeModelDocumentDirty(listener: () => void): () => void {
276
+ modelDirtyListeners.add(listener);
277
+ return () => { modelDirtyListeners.delete(listener); };
278
+ }
279
+
280
+ export function modelDocumentDirty(blend: string): boolean {
281
+ return runtime?.document === blend && runtime.dirty;
282
+ }
283
+
284
+ /** How long the workbench's call waits for the engine to hold the model: a cold boot of the engine. */
285
+ const MODEL_HELD_MS = 120_000;
286
+
287
+ /** The runtime holding `blend`, once it does. */
288
+ async function runtimeHolding(blend: string): Promise<BlenderRuntime> {
289
+ if (!(await waitUntil(() => runtime?.document === blend, MODEL_HELD_MS)) || !runtime) {
290
+ throw new Error(`Blender does not hold ${blend}.`);
291
+ }
292
+ return runtime;
293
+ }
294
+
295
+ /** The project's `.blend` the engine holds now, or `null` while it holds none. */
296
+ export function heldModelDocument(): string | null {
297
+ return runtime?.document ?? null;
298
+ }
299
+
300
+ /** Bring the project's `.blend` up to the model the engine holds, for a reader that takes the
301
+ * file's bytes as the model's (a cook's or an export's hash beside what the engine exported). */
302
+ export async function flushModelDocument(blend: string): Promise<void> {
303
+ const owner = runtime;
304
+ if (owner?.document !== blend || !owner.dirty) return;
305
+ const saved = await owner.saveDocument(saveScript ?? undefined);
306
+ if (saved !== null) modelDocumentSaved(owner, saved);
307
+ }
308
+
309
+ export async function saveModelDocument(blend: string): Promise<void> {
310
+ const owner = await runtimeHolding(blend);
311
+ const saved = await owner.saveDocument(saveScript ?? undefined);
312
+ if (saved !== null) modelDocumentSaved(owner, saved);
313
+ }
314
+
315
+ /** THE SCRIPT EVERY SAVE RUNS, in the save's own turn, on the scene it wrote (`BlenderRuntime.saveDocument`), as its
316
+ * setter names it for the document saved; one at a time, until its removal. */
317
+ let saveScript: SaveScript | null = null;
318
+
319
+ export function setModelDocumentSaveScript(script: SaveScript): () => void {
320
+ if (saveScript) throw new Error('A save script is already set for model documents.');
321
+ saveScript = script;
322
+ return () => { if (saveScript === script) saveScript = null; };
323
+ }
324
+
325
+ /** A SAVE THAT LANDED A `.blend` IN THE PROJECT, as its listeners hear it: the file it wrote, what the save script did
326
+ * on the scene it wrote (null where none ran: `setModelDocumentSaveScript`), and the carry of that script's files into
327
+ * the project at the same paths, as an export's are (`blender-gltf-export.verbs.ts`), which the listener runs when it
328
+ * is ready for them. A script that raised has nothing to carry. */
329
+ export interface ModelDocumentSaved {
330
+ readonly blend: string;
331
+ readonly script: { readonly output: string; readonly error: string | null; readonly unstaged: readonly string[] } | null;
332
+ carry(): Promise<void>;
333
+ }
334
+
335
+ /** EACH SAVE THAT LANDED A `.blend` IN THE PROJECT: the working copy's (a person's Save, the workbench's autosave), an
336
+ * export's flush and the save that retires a document alike. What a listener answers is its work on that save (the
337
+ * carry and what follows it), which reads files, not Blender: a retirement waits for it ({@link retireRuntime}). */
338
+ type ModelDocumentSavedListener = (saved: ModelDocumentSaved) => Promise<unknown> | void;
339
+ const savedListeners = new Set<ModelDocumentSavedListener>();
340
+ /** The listeners' work on each runtime's saves, not yet done. */
341
+ const savedWork = new Map<BlenderRuntime, Set<Promise<unknown>>>();
342
+
343
+ /** Whether the project's file already holds these bytes. A save script's export of an unchanged scene (a call that
344
+ * read the model, a save with nothing changed) writes the same bytes, and a rewrite of them is still a change to
345
+ * what watches the file: the game's dev server reloads every page on it. So an unchanged file is left as it is. */
346
+ async function holds(path: string, bytes: Uint8Array): Promise<boolean> {
347
+ const files = editorHost().files;
348
+ // (a file that can't be read is written, as every carry wrote it before)
349
+ if ((await files.stat(path).catch(() => null))?.size !== bytes.byteLength) return false;
350
+ const held = await files.readBytes(path).catch(() => null);
351
+ return held !== null && held.byteLength === bytes.byteLength && held.every((byte, at) => byte === bytes[at]);
352
+ }
353
+
354
+ function modelDocumentSaved(owner: BlenderRuntime, saved: SavedDocument): void {
355
+ const files = saved.script?.error === null ? saved.script.files : [];
356
+ const heard: ModelDocumentSaved = {
357
+ blend: saved.document,
358
+ script: saved.script === null ? null : { output: saved.script.output, error: saved.script.error, unstaged: saved.script.unstaged },
359
+ async carry() {
360
+ for (const file of files) if (!(await holds(file.path, file.bytes))) await editorHost().files.write(file.path, file.bytes);
361
+ },
362
+ };
363
+ for (const listener of [...savedListeners]) {
364
+ const work = listener(heard);
365
+ if (!work) continue;
366
+ const held = savedWork.get(owner) ?? new Set<Promise<unknown>>();
367
+ savedWork.set(owner, held);
368
+ const settled = work.then(() => {}, () => {}).finally(() => {
369
+ held.delete(settled);
370
+ if (held.size === 0 && savedWork.get(owner) === held) savedWork.delete(owner);
371
+ });
372
+ held.add(settled);
373
+ }
374
+ }
375
+
376
+ export function subscribeModelDocumentSaved(listener: ModelDocumentSavedListener): () => void {
377
+ savedListeners.add(listener);
378
+ return () => { savedListeners.delete(listener); };
379
+ }
380
+
381
+ /** The listeners' work on every save `owner` made, which reads files and not Blender, done. */
382
+ async function savedWorkDone(owner: BlenderRuntime): Promise<void> {
383
+ for (let held = savedWork.get(owner); held !== undefined; held = savedWork.get(owner)) await Promise.all([...held]);
384
+ }
385
+
386
+ /**
387
+ * THE DOCUMENT BLENDER HOLDS, RELEASED WITH ITS SAVES' WORK DONE, before Blender opens another `.blend`: its last
388
+ * save runs the save script in its turn as every save does and is heard, Blender takes no other call from then until
389
+ * the next open (`BlenderRuntime.releaseDocument`), so no edit lands unsaved, and the listeners' work on its saves is
390
+ * waited for. A save that fails throws, and Blender keeps the document, its history and its ownership.
391
+ */
392
+ async function releaseDocument(owner: BlenderRuntime, next: string): Promise<void> {
393
+ await owner.releaseDocument(next, { ...(saveScript ? { after: saveScript } : {}), landed: (saved) => modelDocumentSaved(owner, saved) });
394
+ await savedWorkDone(owner);
395
+ }
396
+
397
+ /**
398
+ * A RUNTIME RETIRED WITH ITS SAVES' WORK DONE, for every retirement (a reload, a stop, the session's close): its last
399
+ * save is the document's as {@link releaseDocument}'s is, taken as the runtime stops. A save that fails throws, and the
400
+ * runtime stays alive with its document.
401
+ */
402
+ async function retireRuntime(owner: BlenderRuntime): Promise<void> {
403
+ await owner.stop({ ...(saveScript ? { after: saveScript } : {}), landed: (saved) => modelDocumentSaved(owner, saved) });
404
+ await savedWorkDone(owner);
405
+ }
406
+
407
+ /**
408
+ * THE ASSET LIBRARIES EVERY BLENDER HAS (Preferences > File Paths > Asset Libraries), by name: set in the running
409
+ * Blender at once (once it is up, if it is starting), and in each Blender after it starts ({@link startedRuntime}).
410
+ * Blender's preferences live as long as its worker, whatever `.blend` it opens. The removal leaves a running Blender's
411
+ * library as it is.
412
+ */
413
+ const assetLibraries = new Map<string, BlenderAssetLibrary>();
414
+
415
+ export function setBlenderAssetLibrary(library: BlenderAssetLibrary): () => void {
416
+ assetLibraries.set(library.name, library);
417
+ if (runtime?.project != null) void runtime.setAssetLibrary(library).catch(refusedLibrary(library));
418
+ return () => { if (assetLibraries.get(library.name) === library) assetLibraries.delete(library.name); };
419
+ }
420
+
421
+ const refusedLibrary = (library: BlenderAssetLibrary) => (error: unknown): void =>
422
+ editorHost().console.error(`Blender did not take the asset library ${library.name} (${library.directory}): ${error instanceof Error ? error.message : String(error)}`, 'blender-asset-library');
423
+
424
+ /**
425
+ * THE PAGE'S BLENDER, started at `project` if it is not yet: its boot claimed now, holding no document, and the asset
426
+ * libraries every Blender has set once it is up. Whatever needs it started waits on its start (`BlenderRuntime.start`,
427
+ * one per worker), so an open that arrives while it starts waits for it rather than starting another. A start that
428
+ * fails is discarded here, and whoever waited on it says why; the next one makes a new Blender.
429
+ */
430
+ function startedRuntime(project: string): BlenderRuntime {
431
+ const session = pageRuntime();
432
+ if (session.project !== null) return session;
433
+ setStarting(session);
434
+ void session.start(project).then(async () => {
435
+ if (startingSession === session) setStarting(null);
436
+ for (const library of assetLibraries.values()) {
437
+ if (runtime !== session) return;
438
+ await session.setAssetLibrary(library).catch(refusedLibrary(library));
439
+ }
440
+ }, () => {
441
+ if (startingSession === session) setStarting(null);
442
+ if (session.startFailed && runtime === session) terminateBlenderRuntime();
443
+ });
444
+ return session;
445
+ }
446
+
447
+ /** The page's Blender while its start is under way, which an open waits on before its own file's load. */
448
+ let startingSession: BlenderRuntime | null = null;
449
+ const startingListeners = new Set<() => void>();
450
+ function notifyBlenderModelState(): void {
451
+ for (const listener of [...startingListeners]) listener();
452
+ }
453
+ function setStarting(session: BlenderRuntime | null): void {
454
+ startingSession = session;
455
+ notifyBlenderModelState();
456
+ }
457
+
458
+ /** Whether the page's Blender is starting: the first open, or one after a reload or a failure, waits on it. */
459
+ export function blenderStarting(): boolean {
460
+ return startingSession !== null && startingSession === runtime;
461
+ }
462
+
463
+ /** Hear Blender start, and start or finish changing the model it holds. */
464
+ export function subscribeBlenderStarting(listener: () => void): () => void {
465
+ startingListeners.add(listener);
466
+ return () => { startingListeners.delete(listener); };
467
+ }
468
+
469
+ /** `document` opened in `session`, Blender's own open; claimed at the call. A Blender that could not open it is
470
+ * discarded, so the next open makes a new one rather than reaching a half-opened model. */
471
+ async function openInRuntime(session: BlenderRuntime, document: string): Promise<DocumentOpened> {
472
+ try {
473
+ return await session.openDocument(document);
474
+ } catch (error) {
475
+ if (runtime === session) terminateBlenderRuntime();
476
+ throw error;
477
+ }
478
+ }
479
+
480
+ type DocumentTable = Awaited<ReturnType<EditorHostProject['documentTable']>>;
481
+
482
+ /** A project's table lists a model: a `.blend` a finder found, the `model:` entries (`models.finder.ts`). */
483
+ const listsModel = (table: DocumentTable): boolean =>
484
+ table.entries.some((entry) => entry.kind === 'model' && entry.source?.path.endsWith('.blend') === true);
485
+
486
+ /** The project Blender was started early for, once per project this page opens (until its session ends). */
487
+ let startedEarlyFor: string | null = null;
488
+
489
+ /**
490
+ * BLENDER STARTED AS THE PROJECT OPENS, once its table lists a model: started holding no document, so the first model
491
+ * opened waits only for what is left of the start and then for its own file's load, and every later one opens in the
492
+ * same Blender ({@link openModelDocumentBlend}). Read from the table the project has published and each one after it,
493
+ * without asking for one: this package activates on its finder (`onFinder:modelsFromBlendFiles`) as the project's
494
+ * first table is resolved. Taken in turn with the documents' opens and retirements, so an open already under way
495
+ * starts Blender itself. Once per project the page opens; returns the unsubscribe.
496
+ */
497
+ export function startBlenderWithProject(): () => void {
498
+ const host = editorHost();
499
+ const ended = host.session.onEnded(() => { startedEarlyFor = null; });
500
+ const tables = host.project.onDocumentTable((table) => {
501
+ if (!host.session.open() || !listsModel(table)) return;
502
+ const project = host.projectLocalState.projectRootPath();
503
+ if (project === null || startedEarlyFor === project) return;
504
+ startedEarlyFor = project;
505
+ void queueModelLifecycle(async () => {
506
+ if (runtime?.project != null || !host.session.open() || host.projectLocalState.projectRootPath() !== project) return;
507
+ // (said in the session journal, so a model's open can be read against the start it waited for)
508
+ const end = beginBlenderWork('Starting Blender');
509
+ startedRuntime(project).start(project).then(end, end);
510
+ });
511
+ });
512
+ return () => { tables(); ended(); };
513
+ }
514
+
515
+ export async function snapshotModelDocument(blend: string): Promise<Uint8Array> {
516
+ return (await runtimeHolding(blend)).snapshotDocument();
517
+ }
518
+
519
+ /**
520
+ * THE DOCUMENT OPENED AGAIN, in turn with the documents' opens and switches: from its project file (a revert,
521
+ * discarding its changes) or as `bytes`, its unsaved state (a backup's restore). A failure before Blender's own open
522
+ * (its file's staging) keeps the running Blender with the document it held; one inside Blender's open discards it,
523
+ * as any failed open does.
524
+ */
525
+ async function reopenModelDocument(blend: string, bytes?: Uint8Array): Promise<void> {
526
+ // (waited for outside the queue, which the open of `blend` may still be in)
527
+ const session = await runtimeHolding(blend);
528
+ await queueModelLifecycle(async () => {
529
+ if (runtime !== session || session.document !== blend) throw new Error(`Blender does not hold ${blend}.`);
530
+ try {
531
+ await session.openDocument(blend, bytes);
532
+ } catch (error) {
533
+ if (!(error instanceof DocumentNotOpened) && runtime === session) terminateBlenderRuntime();
534
+ throw error;
535
+ }
536
+ });
537
+ }
538
+
539
+ /** The document opened again from its project file, discarding its changes. */
540
+ export async function revertModelDocument(blend: string): Promise<void> {
541
+ await reopenModelDocument(blend);
542
+ }
543
+
544
+ /** The document opened again as `bytes`, its unsaved state (a backup). */
545
+ export async function restoreModelDocument(blend: string, bytes: Uint8Array): Promise<void> {
546
+ await reopenModelDocument(blend, bytes);
547
+ }
548
+
292
549
  /**
293
550
  * OPEN A `.blend` IN THE ENGINE — the Model document's own call, the WS-F save
294
- * path run backwards. `session.py` opens the named file at start and saves
295
- * back to it, so naming it here IS opening it.
551
+ * path run backwards. One Blender serves the page's documents one after
552
+ * another, as a desktop Blender does: opening another `.blend` saves the one it
553
+ * holds and opens the next in it (`open-document`, Blender's own open, which
554
+ * `session.py` saves back to). A reload, another project or a Blender whose start
555
+ * failed takes a new worker.
296
556
  *
297
- * The worker owns one file. Validate ownership before publishing a context;
298
- * a refused open must never expose the previous model under a new tab's
299
- * identity. Boot itself can present, so the context must exist before awaiting
300
- * boot completion. Return false when the requesting pane has since unmounted.
557
+ * Blender holds one file at a time. Validate ownership before publishing a
558
+ * context; a refused open must never expose the previous model under a new
559
+ * tab's identity. The open itself presents, so the context must exist before
560
+ * awaiting it. Return false when the requesting pane has since unmounted.
301
561
  */
302
562
  let modelOpenTail: Promise<void> = Promise.resolve();
303
563
  let modelStartup: { readonly session: BlenderRuntime; readonly project: string; readonly blend: string } | null = null;
304
564
 
565
+ /** How many model opens, switches and retirements are queued or under way. */
566
+ let modelLifecycles = 0;
567
+
305
568
  function queueModelLifecycle<T>(operation: () => Promise<T>): Promise<T> {
569
+ modelLifecycles += 1;
570
+ notifyBlenderModelState();
306
571
  const result = modelOpenTail.then(operation);
307
572
  modelOpenTail = result.then(() => {}, () => {});
573
+ void modelOpenTail.then(() => { modelLifecycles -= 1; notifyBlenderModelState(); });
308
574
  return result;
309
575
  }
310
576
 
577
+ /** Whether Blender is starting or changing the model it holds (an open, a switch, a retirement): a read of its
578
+ * model waits for it rather than failing. */
579
+ export function blenderModelChanging(): boolean {
580
+ return modelLifecycles > 0 || blenderStarting();
581
+ }
582
+
583
+ /** Why Blender holds another document than the editor shows, in a person's words, or null. */
584
+ export function blenderModelConflict(): string | null {
585
+ return modelDocumentConflict();
586
+ }
587
+
311
588
  export async function openModelDocumentBlend(
312
589
  binding: ModelDocumentBinding,
313
590
  publish: () => void,
591
+ reload = false,
314
592
  ): Promise<boolean> {
315
593
  let published = false;
316
594
  const publishOnce = () => {
@@ -332,12 +610,13 @@ export async function openModelDocumentBlend(
332
610
  }
333
611
  // Only one handoff may own stop/start. Re-check the mounted requester after
334
612
  // every wait: a rapid B -> C selection must not let B publish into C.
335
- return queueModelLifecycle(() => openBoundModelDocument(binding, publishOnce));
613
+ return queueModelLifecycle(() => openBoundModelDocument(binding, publishOnce, reload));
336
614
  }
337
615
 
338
616
  async function openBoundModelDocument(
339
617
  binding: ModelDocumentBinding,
340
618
  publish: () => void,
619
+ reload: boolean,
341
620
  ): Promise<boolean> {
342
621
  const host = editorHost();
343
622
  if (!host.session.open()) throw new Error('Opening a model requires an open project session.');
@@ -345,40 +624,51 @@ async function openBoundModelDocument(
345
624
  if (project === null) throw new Error('Opening a model requires a project path.');
346
625
  if (!modelDocumentMayOpen(binding)) return false;
347
626
  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
627
+ // (each document named, so the session journal says which document gave way to which)
628
+ const handoff = (reloading: boolean) =>
629
+ beginBlenderWork(`Saving ${previous?.document ?? 'the previous model'} before ${reloading ? 'reopening' : 'opening'} ${binding.blend}`);
630
+ if (previous && (reload || previous.startFailed || (previous.project !== null && previous.project !== project))) {
631
+ // A NEW BLENDER: the old worker's file is saved and its saves' work done before it stops. If
350
632
  // 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(); }
633
+ const end = handoff(reload);
634
+ try { await retireRuntime(previous); } finally { end(); }
353
635
  if (runtime === previous) terminateBlenderRuntime();
354
636
  if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
355
637
  }
356
- const session = blenderRuntime();
357
- // start claims its resource synchronously; its first frame may arrive before
358
- // the returned promise resolves. The getter above rejects a conflicting file.
359
- const started = session.start(project, binding.blend);
638
+ const switching = previous?.document != null && previous.document !== binding.blend ? previous : null;
639
+ let session: BlenderRuntime;
640
+ let opened: Promise<unknown>;
641
+ try {
642
+ if (switching) {
643
+ // ANOTHER `.blend` IN THE SAME BLENDER: the one it holds is saved and its saves' work done
644
+ // first. If saving fails Blender keeps it; neither its history nor its ownership moves.
645
+ const end = handoff(false);
646
+ try { await releaseDocument(switching, binding.blend); } finally { end(); }
647
+ if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
648
+ // What was the old document's goes with it, as a worker's does when it stops: its history
649
+ // (Blender's own load resets it too), its save notices and a Save As the tab had yet to follow.
650
+ forgetDocument();
651
+ }
652
+ session = startedRuntime(project);
653
+ // The open claims the document synchronously; its first frame may arrive before the
654
+ // returned promise resolves. A document Blender already holds (a remounted pane, a tab that
655
+ // followed a Save As) waits only for the start it may still be making.
656
+ opened = session.document === binding.blend ? session.start(project) : openInRuntime(session, binding.blend);
657
+ } finally {
658
+ // Every way out of a switch ends its release: the next document's open, once asked for, ends it in its own
659
+ // turn; any other way out (abandoned, failed) keeps the document Blender holds.
660
+ switching?.keepDocument();
661
+ }
360
662
  const starting = { session, project, blend: binding.blend };
361
663
  modelStartup = starting;
362
664
  try {
363
665
  publish();
364
- await started;
365
- } 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.
370
- if (session.startFailed && runtime === session) terminateBlenderRuntime();
371
- throw error;
666
+ await opened;
372
667
  } finally {
373
668
  if (modelStartup === starting) modelStartup = null;
374
669
  }
375
670
  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.
671
+ // 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
672
  const shown = (await runtimeView()).snapshot();
383
673
  if (!host.session.open() || !modelDocumentMayOpen(binding)) return false;
384
674
  const latest = session.presented;
@@ -397,42 +687,15 @@ async function openBoundModelDocument(
397
687
  * and the panel read one thing.
398
688
  *
399
689
  * 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
690
+ * start Blender, so these answer `null` when this tab
401
691
  * has no started session — exactly the read `blender-status` makes, and for
402
- * the same reason.
692
+ * the same reason — or a started one that holds no model yet.
403
693
  */
404
694
  export function blenderSessionStarted(): boolean {
405
- return runtime?.project != null && modelDocumentConflict() === null;
695
+ return runtime?.project != null && runtime.document !== null && modelDocumentConflict() === null;
406
696
  }
407
697
 
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
- */
698
+ /** 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
699
  let rnaVersion = 0;
437
700
  const rnaListeners = new Set<() => void>();
438
701
 
@@ -455,9 +718,9 @@ export function noteBlenderRnaChanged(): void {
455
718
  for (const listener of [...rnaListeners]) listener();
456
719
  }
457
720
 
458
- export async function blenderRna(path: string, names?: number): Promise<BlenderRnaView | null> {
721
+ export async function blenderRna(path: string): Promise<BlenderRnaView | null> {
459
722
  if (!blenderSessionStarted()) return null;
460
- return blenderRuntime().rna(path, names);
723
+ return blenderRuntime().rna(path);
461
724
  }
462
725
 
463
726
  export async function blenderRnaContext(
@@ -487,51 +750,7 @@ export async function blenderViewShading(shading: 'WIREFRAME' | 'SOLID' | 'MATER
487
750
  await blenderRuntime().viewShading(shading);
488
751
  }
489
752
 
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
- */
753
+ /** 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
754
  export async function blenderExecute(code: string, history = true, label = 'Blender Python'): Promise<BlenderExecuteAnswer> {
536
755
  const session = blenderRuntime();
537
756
  const text = await session.execute(code, history, label);
@@ -547,6 +766,187 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
547
766
  };
548
767
  }
549
768
 
769
+ // ---------------------------------------------------------------- a film, rendered
770
+
771
+ /** WHERE A RENDER'S TIME GOES, one record per photograph a render or a film takes: the frame's
772
+ * transfer (from its first staged part to its present; none when nothing new was staged), building
773
+ * it into the view it is photographed from, and the photograph itself. Read by `render-movie`
774
+ * beside Blender's own time, so a slow film says which side is slow. */
775
+ const renderTimings: { stageMs: number; applyMs: number; photoMs: number }[] = [];
776
+ /** When the frame being transferred staged its first part, render or viewport; taken at its present. */
777
+ let frameStagedAt: number | null = null;
778
+
779
+ /** The film being encoded (`renderBlenderMovie`), one at a time, and the photographs of the frames
780
+ * rendered and not yet encoded, in order (base64 PNG). */
781
+ let film: { readonly movie: Movie; readonly photographs: string[] } | null = null;
782
+
783
+ async function movieFrames(numbers: readonly number[]): Promise<{ encoded: number; timing: { executeMs: number; stageMs: number; applyMs: number; photoMs: number; encodeMs: number } }> {
784
+ const current = film;
785
+ if (!current) throw new Error('No film is being rendered.');
786
+ const { movie } = current;
787
+ // ONE CALL TAKES THE CHUNK: each frame set, then photographed as the viewport presents it through
788
+ // the scene camera (session.py's `photograph_frame`): the present ships only what moved.
789
+ const code = `
790
+ import bpy, sys
791
+ photograph = next(m.photograph_frame for m in list(sys.modules.values()) if hasattr(m, "photograph_frame"))
792
+ s = bpy.context.scene
793
+ kept = s.frame_current
794
+ try:
795
+ for f in ${JSON.stringify(numbers)}:
796
+ s.frame_set(f)
797
+ photograph(${movie.width}, ${movie.height})
798
+ finally:
799
+ s.frame_set(kept)
800
+ `;
801
+ const timedFrom = renderTimings.length;
802
+ const executeAt = performance.now();
803
+ const answer = await blenderExecute(code, false, 'render-movie');
804
+ const executeMs = performance.now() - executeAt;
805
+ const taken = renderTimings.slice(timedFrom);
806
+ const encodeAt = performance.now();
807
+ if (!answer.executed) throw new Error(`Blender could not render frames ${numbers[0]}-${numbers.at(-1)}: ${answer.error}`);
808
+ const shots = current.photographs.splice(0);
809
+ if (shots.length !== numbers.length) throw new Error(`Blender rendered ${numbers.length} frames and ${shots.length} photographs were taken.`);
810
+ for (const shot of shots) {
811
+ const png = bytesFromBase64(shot);
812
+ const bitmap = await createImageBitmap(new Blob([png as BlobPart], { type: 'image/png' }));
813
+ try { await movie.frame(bitmap); } finally { bitmap.close(); }
814
+ }
815
+ const mean = (pick: (t: (typeof taken)[number]) => number): number => (taken.length ? taken.reduce((n, t) => n + pick(t), 0) / taken.length : 0);
816
+ const per = numbers.length || 1;
817
+ 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 } };
818
+ }
819
+
820
+ /** The scene's sound over frames `from` to `to`, mixed, and a movie begun with it encoded first: its track starts at
821
+ * the movie's first frame, so a movie from a later frame hears the sound from that frame on. */
822
+ async function beginFilm(size: { readonly width: number; readonly height: number }, fps: number, from: number, to: number): Promise<{ strips: number; speakers: number } | null> {
823
+ const session = blenderRuntime();
824
+ const host = editorHost();
825
+ const project = host.projectLocalState.projectRootPath();
826
+ const sound = await mixSceneSound(await session.soundScene(from, to), async (file) => {
827
+ if ('packed' in file) return session.readFile(file.packed);
828
+ // a file in the project is read from the project; the engine mirrors the project at its own path
829
+ return project !== null && file.path.startsWith(`${project}/`)
830
+ ? host.files.readBytes(file.path.slice(project.length + 1))
831
+ : session.readFile(file.path);
832
+ });
833
+ const movie = await beginMovie({ ...size, fps, ...(sound ? { audio: { sampleRate: sound.sampleRate, channels: sound.planes.length } } : {}) });
834
+ try {
835
+ if (sound) await movie.audio(sound.planes);
836
+ } catch (error) {
837
+ movie.cancel();
838
+ throw error;
839
+ }
840
+ film?.movie.cancel();
841
+ film = { movie, photographs: [] };
842
+ return sound ? { strips: sound.strips, speakers: sound.speakers } : null;
843
+ }
844
+
845
+ /** What a movie render is asked for: the project file it is written to, the `.blend` it renders (the open one by
846
+ * default), its first and last frames (frame numbers or marker names; the scene's range by default), its width (the
847
+ * scene's resolution by default; the height keeps the scene's aspect), and the frame rate a caller's own movie is at,
848
+ * which the scene's must equal. */
849
+ interface BlenderMovieRequest {
850
+ readonly out: string;
851
+ readonly document?: string;
852
+ readonly from?: string | number | undefined;
853
+ readonly to?: string | number | undefined;
854
+ readonly width?: number;
855
+ readonly fps?: number;
856
+ }
857
+
858
+ /** Where a movie render stands. */
859
+ interface BlenderMovieRender {
860
+ readonly out: string;
861
+ readonly state: 'rendering' | 'done' | 'failed';
862
+ readonly frame: number;
863
+ readonly frames: number;
864
+ readonly first?: number;
865
+ readonly last?: number;
866
+ readonly width?: number;
867
+ readonly height?: number;
868
+ readonly fps?: number;
869
+ /** The scene's sound the movie carries, or null when the scene plays none. */
870
+ readonly sound?: { readonly strips: number; readonly speakers: number } | null;
871
+ /** What the render cannot do as asked (a scene with no camera). */
872
+ readonly warnings?: readonly string[];
873
+ /** Where the last chunk's time went, per frame, in milliseconds: Blender's render call and, inside it, the frame's
874
+ * transfer, its build into the photograph view and the photograph; then the encode. */
875
+ readonly timing?: { readonly executeMs: number; readonly stageMs: number; readonly applyMs: number; readonly photoMs: number; readonly encodeMs: number };
876
+ readonly codec?: string;
877
+ readonly bytes?: number;
878
+ readonly error?: string;
879
+ }
880
+
881
+ /** The movie renders, by the project file each writes. */
882
+ const movieRenders = commandJobs<BlenderMovieRender>();
883
+
884
+ /** The Timeline of `request.document` (or the open document) rendered with the scene's sound into `request.out`. */
885
+ async function renderBlenderMovie(request: BlenderMovieRequest, report: (render: BlenderMovieRender) => void): Promise<void> {
886
+ let render: BlenderMovieRender = { out: request.out, state: 'rendering', frame: 0, frames: 0 };
887
+ const host = editorHost();
888
+ if (request.document !== undefined) {
889
+ const opened = await host.documents.openForFile(request.document);
890
+ if (opened === null) throw new Error(`No Model document is ${request.document}.`);
891
+ await host.documents.waitForContext(opened);
892
+ }
893
+ const probe = await blenderExecute(`
894
+ import bpy, json
895
+ s = bpy.context.scene
896
+ r = s.render
897
+ 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,
898
+ "percent": r.resolution_percentage, "camera": s.camera.name if s.camera else None,
899
+ "markers": {m.name: m.frame for m in s.timeline_markers}, "cameraMarkers": [m.frame for m in s.timeline_markers if m.camera]}))
900
+ `, false, 'render-movie');
901
+ if (!probe.executed) throw new Error(`Blender could not read the scene: ${probe.error}`);
902
+ const scene = JSON.parse(probe.result.slice(probe.result.indexOf('{'))) as {
903
+ start: number; end: number; fps: number; width: number; height: number; percent: number; camera: string | null;
904
+ markers: Record<string, number>; cameraMarkers: number[];
905
+ };
906
+ if (request.fps !== undefined && Math.abs(scene.fps - request.fps) > 1e-6)
907
+ 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.`);
908
+ const frameOf = (at: string | number | undefined, fallback: number): number => {
909
+ if (at === undefined) return fallback;
910
+ if (typeof at === 'number') return at;
911
+ if (/^-?\d+$/.test(at)) return Number(at);
912
+ const marker = scene.markers[at];
913
+ if (marker === undefined) throw new Error(`The Timeline has no marker "${at}"; it has ${Object.keys(scene.markers).map((name) => `"${name}"`).join(', ') || 'none'}.`);
914
+ return marker;
915
+ };
916
+ const first = frameOf(request.from, scene.start);
917
+ const last = frameOf(request.to, scene.end);
918
+ if (last < first) throw new Error(`It ends (frame ${last}) before it starts (frame ${first}).`);
919
+ const width = request.width ?? Math.round(scene.width * (scene.percent / 100));
920
+ const height = Math.round(width * (scene.height / scene.width));
921
+ const warnings = !scene.camera && !scene.cameraMarkers.some((at) => at <= first)
922
+ ? ['The scene has no camera; Blender renders through the scene camera or a marker-bound one.'] : [];
923
+ const sound = await beginFilm({ width, height }, scene.fps, first, last);
924
+ const movie = film!.movie;
925
+ render = { ...render, frames: last - first + 1, first, last, width: movie.width, height: movie.height, fps: scene.fps, sound, codec: movie.codec, warnings };
926
+ report(render);
927
+ for (let frame = first; frame <= last; frame += 12) {
928
+ const { encoded, timing } = await movieFrames(Array.from({ length: Math.min(12, last - frame + 1) }, (_, i) => frame + i));
929
+ render = { ...render, frame: encoded, timing };
930
+ report(render);
931
+ }
932
+ film = null;
933
+ const bytes = await movie.end();
934
+ await host.files.write(request.out, bytes);
935
+ report({ ...render, state: 'done', bytes: bytes.length });
936
+ }
937
+
938
+ /** Start rendering a movie, answering at once with where it stands. One render at a time. */
939
+ function startBlenderMovie(request: BlenderMovieRequest): BlenderMovieRender {
940
+ if (movieRenders.running()) throw new Error('A movie is already rendering.');
941
+ if (!/\.webm$/i.test(request.out)) throw new Error(`A movie is written as .webm, not ${request.out}.`);
942
+ return movieRenders.start(request.out, { out: request.out, state: 'rendering', frame: 0, frames: 0 }, (report) =>
943
+ renderBlenderMovie(request, report).catch((thrown: unknown) => {
944
+ film?.movie.cancel();
945
+ film = null;
946
+ throw thrown;
947
+ }));
948
+ }
949
+
550
950
  /**
551
951
  * A Save As the worker reported (`BlenderRuntimeOptions.documentMoved`) that the open Model tab
552
952
  * has not followed yet. Taken by the {@link blenderExecute} whose script moved it, once that
@@ -554,6 +954,9 @@ export async function blenderExecute(code: string, history = true, label = 'Blen
554
954
  */
555
955
  let unfollowedMove: { readonly owner: BlenderRuntime; readonly from: string; readonly document: string } | null = null;
556
956
 
957
+ /** A followed Save As waiting for the table to list its new file (`followMovedDocument`); calling it stops waiting. */
958
+ let awaitingListing: (() => void) | null = null;
959
+
557
960
  /**
558
961
  * THE MODEL TAB FOLLOWS BLENDER'S SAVE AS. The runtime already holds the new file and its saves
559
962
  * go there (`session.py::follow_open_file`); what is left is the tab. Until it moves, the editor
@@ -563,13 +966,17 @@ let unfollowedMove: { readonly owner: BlenderRuntime; readonly from: string; rea
563
966
  * its binding. Re-activating the old tab later opens the old file the ordinary way.
564
967
  *
565
968
  * 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.
969
+ * `.blend`) is moved; a person who has since switched elsewhere is not pulled back. A file the
970
+ * project's finders list may not be in the project folder yet: a script's call through the
971
+ * project's Blender MCP answers before its Mirror lands the files the script saved, so the tab
972
+ * follows when the table first lists it. A new file outside every finder's `include` is said, not
973
+ * guessed at.
568
974
  */
569
975
  async function followMovedDocument(owner: BlenderRuntime): Promise<void> {
570
976
  const move = unfollowedMove;
571
977
  if (!move || move.owner !== owner) return;
572
978
  unfollowedMove = null;
979
+ awaitingListing?.();
573
980
  const host = editorHost();
574
981
  // The tab showing Blender: the file Blender left, or, after an earlier Save As to an unlisted file, the tab
575
982
  // that one left.
@@ -580,35 +987,56 @@ async function followMovedDocument(owner: BlenderRuntime): Promise<void> {
580
987
  const shown = shownModelBlend();
581
988
  return shown === shownFrom || (shown === undefined && host.documents.activeId() === BLENDER_RUNTIME_DOCUMENT_ID);
582
989
  };
990
+ const listing = (table: Awaited<ReturnType<EditorHostProject['documentTable']>>) =>
991
+ table.entries.find(candidate => candidate.kind === 'model' && candidate.source?.path === move.document);
992
+ /** A tab that could not move keeps working on the file Blender holds, as for an unlisted file. */
993
+ const keepShowing = () => {
994
+ if (stillShowingOld()) unlistedFollow = { owner, from: shownFrom, document: move.document };
995
+ };
996
+ const follow = async (id: string) => {
997
+ if (await host.workspace.open({ kind: 'document', id })) return;
998
+ keepShowing();
999
+ 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');
1000
+ };
1001
+ const failed = (error: unknown) => {
1002
+ keepShowing();
1003
+ host.console.error(`The Model tab could not follow Blender to ${move.document}: ${error instanceof Error ? error.message : String(error)}`, 'blender-document');
1004
+ };
583
1005
  if (!stillShowingOld()) return;
584
1006
  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
- );
1007
+ const entry = listing(await host.project.documentTable());
1008
+ if (!stillShowingOld()) return;
1009
+ if (entry) {
1010
+ await follow(entry.id);
598
1011
  return;
599
1012
  }
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');
1013
+ // Meanwhile the tab works on the file Blender holds.
1014
+ unlistedFollow = { owner, from: shownFrom, document: move.document };
1015
+ if (host.project.finderIncludes(move.document)) {
1016
+ const stop = host.project.onDocumentTable((table) => {
1017
+ const listed = listing(table);
1018
+ if (!listed) return;
1019
+ release();
1020
+ if (!stillShowingOld()) return;
1021
+ unlistedFollow = null;
1022
+ follow(listed.id).catch(failed);
1023
+ });
1024
+ const release = () => {
1025
+ stop();
1026
+ if (awaitingListing === release) awaitingListing = null;
1027
+ };
1028
+ awaitingListing = release;
1029
+ return;
604
1030
  }
1031
+ host.console.warn(
1032
+ `Blender now has ${move.document} open and the model saves there. No Model document lists that file ` +
1033
+ `(no document finder in the project's adapter includes it), so the ${shownFrom} tab keeps showing ` +
1034
+ `Blender, which now edits ${move.document}; ${shownFrom} keeps what it last saved. Save the model ` +
1035
+ `where the adapter's finder lists models to give it a Model tab of its own.`,
1036
+ 'blender-document',
1037
+ );
605
1038
  } 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 };
1039
+ failed(error);
612
1040
  }
613
1041
  }
614
1042
 
@@ -648,26 +1076,19 @@ export async function blenderOutliner(
648
1076
  /** THE NODE-TREE DOOR, in-page — one material's shader node tree, which the
649
1077
  * Shading workspace's node view draws (`../contributions/blender-node-*`).
650
1078
  * 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> {
1079
+ export async function blenderNodeTree(): Promise<BlenderNodeTree | null> {
655
1080
  if (!blenderSessionStarted()) return null;
656
- return blenderRuntime().nodeTree(options);
1081
+ return blenderRuntime().nodeTree();
657
1082
  }
658
1083
 
659
1084
  /** THE UV DOOR, in-page — one mesh's UV layout, which the UV Editing
660
1085
  * workspace's view draws (`../contributions/blender-uv-*`). Same rule as the
661
1086
  * doors above: it never starts the engine. */
662
- export async function blenderUvLayout(options?: {
663
- object?: string;
664
- uvLayer?: string;
665
- }): Promise<BlenderUvLayout | null> {
1087
+ export async function blenderUvLayout(): Promise<BlenderUvLayout | null> {
666
1088
  if (!blenderSessionStarted()) return null;
667
- return blenderRuntime().uvLayout(options);
1089
+ return blenderRuntime().uvLayout();
668
1090
  }
669
1091
 
670
-
671
1092
  /** THE CLIP DOOR, in-page — one action as three.js keyframe tracks plus the
672
1093
  * scene's frame range and the Timeline's summary columns. Called when the
673
1094
  * action's own revision moves, never per played frame. */
@@ -683,6 +1104,30 @@ export async function blenderActionClip(options?: {
683
1104
  return blenderRuntime().actionClip(options);
684
1105
  }
685
1106
 
1107
+ /** THE DOCUMENT AS A COMPONENT OR A LEVEL, by Blender's own glTF exporter (`session.py`'s
1108
+ * `gltf-export`, RFC 0007), and the bytes of a file it wrote in the module filesystem, for the
1109
+ * caller to carry into the project. Neither starts the engine. */
1110
+ 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> {
1111
+ if (!blenderSessionStarted()) return null;
1112
+ return blenderRuntime().gltfExport(options);
1113
+ }
1114
+
1115
+ /** Present the document once now (after a game over the standard artifact ends: `blender-runtime.document.tsx`). */
1116
+ export async function blenderPresentDocument(): Promise<void> {
1117
+ if (!blenderSessionStarted()) return;
1118
+ await blenderRuntime().present();
1119
+ }
1120
+
1121
+ export async function blenderEngineFile(path: string): Promise<Uint8Array> {
1122
+ return blenderRuntime().readFile(path);
1123
+ }
1124
+
1125
+ /** Read the scene animation for the Timeline preview and glTF export (`session.py`'s
1126
+ * `rna_scene_movie`). Returns null without starting an absent engine session. */
1127
+ export async function blenderSceneMovie(): Promise<BlenderSceneMovie | null> {
1128
+ if (!blenderSessionStarted()) return null;
1129
+ return blenderRuntime().sceneMovie();
1130
+ }
686
1131
 
687
1132
  export async function blenderOutlinerSet(
688
1133
  path: string,
@@ -695,46 +1140,11 @@ export async function blenderOutlinerSet(
695
1140
  return written;
696
1141
  }
697
1142
 
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
- };
1143
+ /** 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. */
1144
+ 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) => ({
1145
+ id,
1146
+ run: (args?: Record<string, unknown>) => driveNodeView({ type: 'blender-node-view', ...args, action: id }),
1147
+ }));
738
1148
 
739
1149
  /**
740
1150
  * `blender-node-view` — the node editor's actions and its state, as one verb.
@@ -758,10 +1168,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
758
1168
  break;
759
1169
  case 'view-all':
760
1170
  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.
1171
+ // A successful framing action clears the prior refusal.
765
1172
  setNodeViewState({ refusal: null });
766
1173
  break;
767
1174
  case 'zoom': {
@@ -781,9 +1188,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
781
1188
  });
782
1189
  break;
783
1190
  }
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.
1191
+ // Expose the same named editing refusals through commands and pointer handlers.
787
1192
  case 'move':
788
1193
  refuseNodeViewGesture(
789
1194
  `Moving "${String(cmd['node'] ?? 'a node')}" writes Node.location — editing parity is not the program.`,
@@ -825,7 +1230,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
825
1230
  interface RuntimeView {
826
1231
  readonly root: THREE.Group;
827
1232
  stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void;
828
- applyFrame(frame: unknown): unknown;
1233
+ applyFrame(frame: unknown): ReturnType<BlenderRuntimeView['applyFrame']>;
829
1234
  snapshot(): ReturnType<BlenderRuntimeView['snapshot']>;
830
1235
  drawStatistics(): ReturnType<BlenderRuntimeView['drawStatistics']>;
831
1236
  setDrawBatching(enabled: boolean): void;
@@ -846,6 +1251,8 @@ interface RuntimeView {
846
1251
 
847
1252
  let runtime: BlenderRuntime | null = null;
848
1253
  const historyResources = new Set<string>();
1254
+ /** Which document Blender holds, counted: each one given up ({@link forgetDocument}) is a new count. */
1255
+ let heldDocument = 0;
849
1256
 
850
1257
  function invalidateBlenderHistory(): void {
851
1258
  if (historyResources.size) editorHost().history.invalidate([...historyResources]);
@@ -857,35 +1264,40 @@ let captureLifetime: AbortController | null = null;
857
1264
  * terminate, and the host door only exists inside a running editor. */
858
1265
  let watchingSessionEnd = false;
859
1266
 
860
- /** Diagnostics must not prevent teardown if the host has already detached. */
861
- function beginBlenderWork(label: string): () => void {
1267
+ /** Blender's work as a wait (`session.beginWork`): one a person follows ("Starting Blender") is theirs and the
1268
+ * session's, and an engine's own step (`internal`) the session's alone. Reporting it must not prevent teardown if the
1269
+ * host has already detached. */
1270
+ function beginBlenderWork(label: string, internal = false): () => void {
862
1271
  try {
863
- const end = editorHost().session.beginWork(`Blender: ${label}`);
1272
+ const end = editorHost().session.beginWork(label, internal ? { internal } : undefined);
864
1273
  return () => { try { end(); } catch { /* preserve the operation's result */ } };
865
1274
  } catch { return () => {}; }
866
1275
  }
867
1276
 
1277
+ /** What belongs to the document Blender held and goes with it: its history, its save notices and a Save As the tab
1278
+ * had yet to follow. */
1279
+ function forgetDocument(): void {
1280
+ heldDocument += 1;
1281
+ invalidateBlenderHistory();
1282
+ clearModelDocumentSaving();
1283
+ unfollowedMove = null;
1284
+ unlistedFollow = null;
1285
+ awaitingListing?.();
1286
+ }
1287
+
868
1288
  function terminateBlenderRuntime(): void {
869
- const end = beginBlenderWork('invalidating history and releasing runtime');
1289
+ const end = beginBlenderWork('releasing the runtime', true);
870
1290
  try {
871
- invalidateBlenderHistory();
1291
+ forgetDocument();
872
1292
  captureLifetime?.abort();
873
1293
  captureLifetime = null;
874
1294
  runtime?.terminate();
875
1295
  runtime = null;
876
- lastCapture = null;
1296
+ if (startingSession !== null) setStarting(null);
877
1297
  } finally { end(); }
878
1298
  }
879
1299
 
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.
1300
+ // Terminate the Blender worker on session.onEnded, including when the browser refuses window.close on a person-opened tab.
889
1301
  function watchSessionEnd(): void {
890
1302
  if (watchingSessionEnd) return;
891
1303
  watchingSessionEnd = true;
@@ -895,10 +1307,9 @@ function watchSessionEnd(): void {
895
1307
 
896
1308
  async function stopBlenderRuntime(): Promise<void> {
897
1309
  const owner = runtime;
898
- await owner?.stop();
1310
+ if (owner) await retireRuntime(owner);
899
1311
  if (runtime === owner) terminateBlenderRuntime();
900
1312
  }
901
- let lastCapture: CaptureRequest | null = null;
902
1313
 
903
1314
  const isRuntimeView = (value: unknown): value is RuntimeView =>
904
1315
  typeof value === 'object' &&
@@ -913,13 +1324,7 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
913
1324
 
914
1325
  async function runtimeView(): Promise<RuntimeView> {
915
1326
  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).
1327
+ // Re-read the Model binding while waiting: a presentation can begin before its pane publishes the final document address.
923
1328
  const deadline = Date.now() + 15_000;
924
1329
  let id = presentationDocumentId();
925
1330
  let publication = 'no context';
@@ -946,9 +1351,18 @@ async function runtimeView(): Promise<RuntimeView> {
946
1351
  );
947
1352
  }
948
1353
 
1354
+ /** The page's Blender for a call on its document: refused while it is on another document than the editor shows, and
1355
+ * while it has started holding none. */
949
1356
  export function blenderRuntime(): BlenderRuntime {
950
1357
  const conflict = modelDocumentConflict();
951
1358
  if (conflict) throw new Error(conflict);
1359
+ if (runtime?.project != null && runtime.document === null)
1360
+ throw new Error('Blender has started and holds no model yet: open a Model document (blender-start opens the one the editor shows).');
1361
+ return pageRuntime();
1362
+ }
1363
+
1364
+ /** The page's one Blender handle, made if there is none (not started: {@link startedRuntime} starts it). */
1365
+ function pageRuntime(): BlenderRuntime {
952
1366
  if (runtime !== null) return runtime;
953
1367
  watchSessionEnd();
954
1368
  const lifetime = new AbortController();
@@ -965,12 +1379,20 @@ export function blenderRuntime(): BlenderRuntime {
965
1379
  const discardStreamedFrame = () => {
966
1380
  const staged = streamedFrame;
967
1381
  streamedFrame = null;
1382
+ frameStagedAt = null;
968
1383
  if (staged) staged.view.stageFrame({ session: staged.session, revision: staged.revision, abort: true });
969
1384
  discardRenderFrame();
970
1385
  };
971
1386
  lifetime.signal.addEventListener('abort', discardStreamedFrame, { once: true });
972
1387
  runtime = new BlenderRuntime({
973
- work: beginBlenderWork,
1388
+ // (the worker's own steps: the session's phase, never the person's status bar)
1389
+ work: (label) => beginBlenderWork(label, true),
1390
+ documentSaving: ({ document, note }) => {
1391
+ if (!lifetime.signal.aborted) setModelDocumentSaveNotice(document, note);
1392
+ },
1393
+ documentDirty: () => {
1394
+ for (const listener of [...modelDirtyListeners]) listener();
1395
+ },
974
1396
  history: (entries) => {
975
1397
  const owner = runtime;
976
1398
  if (!owner) throw new Error('Blender history arrived before its runtime');
@@ -981,8 +1403,11 @@ export function blenderRuntime(): BlenderRuntime {
981
1403
  }
982
1404
  if (!entry.resource) throw new Error('A Blender edit has no document resource');
983
1405
  historyResources.add(entry.resource);
1406
+ // (the document it was made in, through a script's Save As, which keeps Blender's history; not one opened since)
1407
+ const held = heldDocument;
984
1408
  const restore = async (direction: 'undo' | 'redo'): Promise<boolean> => {
985
1409
  if (runtime !== owner) throw new Error('This Blender history belongs to a closed worker');
1410
+ if (heldDocument !== held) throw new Error(`This Blender history belongs to ${entry.resource}, which Blender no longer holds`);
986
1411
  const moved = await owner.historyStep(entry.id, direction);
987
1412
  noteBlenderRnaChanged();
988
1413
  // The restored frame arrives before the Outliner's scheduled read.
@@ -1013,6 +1438,7 @@ export function blenderRuntime(): BlenderRuntime {
1013
1438
  if (part.evaluation === 'render') {
1014
1439
  if (!renderFrame) {
1015
1440
  if (part.mesh !== undefined || part.image !== undefined) throw new Error('Render resource arrived before its frame');
1441
+ frameStagedAt = performance.now();
1016
1442
  const detached = BlenderRuntimeView.forPhotograph();
1017
1443
  view.root.updateWorldMatrix(true, false);
1018
1444
  detached.root.matrix.copy(view.root.matrixWorld);
@@ -1026,6 +1452,7 @@ export function blenderRuntime(): BlenderRuntime {
1026
1452
  if (renderFrame) throw new Error('Viewport frame arrived during render transfer');
1027
1453
  if (streamedFrame && streamedFrame.view !== view)
1028
1454
  throw new Error('Blender presenter changed during frame transfer');
1455
+ if (!streamedFrame) frameStagedAt = performance.now();
1029
1456
  view.stageFrame(part);
1030
1457
  streamedFrame = { view, session: part.session, revision: part.revision };
1031
1458
  },
@@ -1046,7 +1473,7 @@ export function blenderRuntime(): BlenderRuntime {
1046
1473
  return {};
1047
1474
  }
1048
1475
  const documentId = presentationDocumentId();
1049
- const endWait = beginBlenderWork('waiting for Model presenter');
1476
+ const endWait = beginBlenderWork('waiting for the Model presenter', true);
1050
1477
  let view: RuntimeView;
1051
1478
  try { view = await runtimeView(); } finally { endWait(); }
1052
1479
  lifetime.signal.throwIfAborted();
@@ -1060,31 +1487,26 @@ export function blenderRuntime(): BlenderRuntime {
1060
1487
  if (rendering && (rendering.source !== view || rendering.sourceFrame !== view.snapshot() || rendering.documentId !== documentId))
1061
1488
  throw new Error('Blender capture source changed before render presentation');
1062
1489
  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`.
1070
- const endApply = beginBlenderWork('applying frame to Model');
1490
+ // Report the presenter’s holding before applying this frame.
1491
+ const endApply = beginBlenderWork('applying a frame to the Model', true);
1492
+ const applyAt = performance.now();
1071
1493
  const applied = (() => {
1072
- try { return presenter.applyFrame(frame) as { held?: unknown } | null | undefined; }
1494
+ try { return presenter.applyFrame(frame); }
1073
1495
  finally { endApply(); }
1074
1496
  })();
1497
+ const appliedAt = performance.now();
1498
+ const stagedAt = frameStagedAt;
1499
+ frameStagedAt = null;
1075
1500
  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
1501
  const answer = (capture: unknown): PresentAnswer => ({
1079
1502
  ...(capture === undefined ? {} : { capture }),
1080
- ...(reports ? { held } : {}),
1503
+ held: applied.held,
1081
1504
  });
1082
1505
  // THE RECORD OF WHAT WAS SUBMITTED, kept only once the frame was taken:
1083
1506
  // a refused frame is not displayed and must not be described as if it
1084
1507
  // were.
1085
1508
  presenter.recordPresentation(description);
1086
1509
  if (!capture) return answer(undefined);
1087
- lastCapture = capture;
1088
1510
  if (!capture.render) return answer(undefined);
1089
1511
  const presented = view.snapshot();
1090
1512
  // A COMPOSITED FRAME IS NOT PHOTOGRAPHED. The compositor already
@@ -1110,14 +1532,10 @@ export function blenderRuntime(): BlenderRuntime {
1110
1532
  // through THAT matrix -- copied into the detached snapshot, never written out
1111
1533
  // here as a quarter turn -- and a fresh camera is placed on it.
1112
1534
  //
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.
1535
+ // Handing the raw Blender numbers to `presentEditorView` would read them
1536
+ // as three.js world space and photograph the model from a misplaced
1537
+ // camera, and a render that never touches the viewport needs no move of
1538
+ // the VIEWPORT onto the render camera and back.
1121
1539
  const { render, position, target, up } = capture;
1122
1540
  if (!position || !target || !up)
1123
1541
  throw new Error(
@@ -1151,22 +1569,13 @@ export function blenderRuntime(): BlenderRuntime {
1151
1569
  renderCamera.position.copy(eye);
1152
1570
  renderCamera.lookAt(focus);
1153
1571
  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.
1572
+ // Transform the photograph’s pose back through the exact signed axis permutation. Python compares it with the submitted pose and rejects any mismatch.
1161
1573
  const photographedFrom = {
1162
1574
  position: eye.clone().applyMatrix4(toBlender).toArray(),
1163
1575
  target: focus.clone().applyMatrix4(toBlender).toArray(),
1164
1576
  up: upward.clone().applyMatrix3(blenderBasis).toArray(),
1165
1577
  };
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.
1578
+ // Retain both poses before capture and Python validation, including a refused photograph.
1170
1579
  view.recordPhotograph({
1171
1580
  sent: { position, target, up },
1172
1581
  photographed: photographedFrom,
@@ -1177,7 +1586,15 @@ export function blenderRuntime(): BlenderRuntime {
1177
1586
  orthographic: render.orthographic,
1178
1587
  },
1179
1588
  });
1589
+ const photoAt = performance.now();
1180
1590
  const display = await photographSnapshot(scene, renderCamera, snapshot, render, assertBinding);
1591
+ // Queue each photograph directly for the movie encoder.
1592
+ if (film) film.photographs.push(display.base64);
1593
+ // (a film's frames are viewport photographs, `rendering` null: they are timed too)
1594
+ if (rendering || film) {
1595
+ renderTimings.push({ stageMs: stagedAt === null ? 0 : applyAt - stagedAt, applyMs: appliedAt - applyAt, photoMs: performance.now() - photoAt });
1596
+ if (renderTimings.length > 64) renderTimings.shift();
1597
+ }
1181
1598
  return answer({ ...display, camera: photographedFrom });
1182
1599
  } finally {
1183
1600
  photographing = false;
@@ -1220,25 +1637,7 @@ export function blenderRuntime(): BlenderRuntime {
1220
1637
  return runtime;
1221
1638
  }
1222
1639
 
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
- */
1640
+ /** 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
1641
  const DEFAULT_BLENDER_DOCUMENT = 'models/model.blend';
1243
1642
 
1244
1643
  const string = (cmd: Record<string, unknown>, key: string): string => {
@@ -1287,7 +1686,10 @@ export async function handleBlenderCommand(cmd: {
1287
1686
  data: {
1288
1687
  started: runtime?.project != null,
1289
1688
  document: host.documents.context(presentationDocumentId()) !== undefined,
1290
- instancing: runtime?.project != null ? (await runtimeView()).drawStatistics() : null,
1689
+ // (the `.blend` Blender holds: none after a start as the project opened, before a model's open)
1690
+ holds: runtime?.document ?? null,
1691
+ instancing: runtime?.project != null && host.documents.context(presentationDocumentId()) !== undefined
1692
+ ? (await runtimeView()).drawStatistics() : null,
1291
1693
  lastOutput: runtime?.lastOutput ?? [],
1292
1694
  },
1293
1695
  };
@@ -1342,44 +1744,43 @@ export async function handleBlenderCommand(cmd: {
1342
1744
  'opens that document (the Model document ships with @volter/editor-blender).',
1343
1745
  };
1344
1746
  }
1747
+ if (cmd.type === 'blender-start') {
1748
+ const conflict = modelDocumentConflict();
1749
+ if (conflict) throw new Error(conflict);
1750
+ // A start that fails is discarded (`startedRuntime`), so the next `blender-start` makes a
1751
+ // new Blender instead of awaiting the same failure.
1752
+ const session = startedRuntime(project!);
1753
+ // THE OPEN MODEL DOCUMENT'S `.blend` WINS over the default: a models
1754
+ // project's document is its own `src/models/<name>.blend`, and a
1755
+ // `blender-start` that named none is asking for the session this tab
1756
+ // is showing, not for a second file beside it.
1757
+ // After a script's Save As to a file no Model lists, the session this tab
1758
+ // shows holds that file (`unlistedFollow`), and a start naming none
1759
+ // asks for it, not for the file the tab was opened on.
1760
+ // The shown file is read as `modelDocumentConflict` reads it (active tab first): the bound pane lags a
1761
+ // tab that just followed a Save As (`../src/start-document.ts`).
1762
+ const document = startDocument({
1763
+ requested: requestedDocument,
1764
+ shown: shownModelBlend(),
1765
+ follow: unlistedFollow !== null && unlistedFollow.owner === session ? unlistedFollow : null,
1766
+ fallback: DEFAULT_BLENDER_DOCUMENT,
1767
+ });
1768
+ const started = await session.start(project!);
1769
+ // A Model document's own open puts its file in this Blender, and this waits for it; a Blender
1770
+ // still holding none (the standing Model of a project with no `.blend`) opens the one asked for.
1771
+ const opened = await queueModelLifecycle(async (): Promise<DocumentOpened | null> => {
1772
+ if (runtime !== session) throw new Error('Blender was discarded before it held a model (its start or open failed); start it again.');
1773
+ if (session.document === null) return openInRuntime(session, document);
1774
+ if (session.document !== document)
1775
+ throw new Error(`Blender resource conflict: the session holds ${session.document}; cannot open ${document}`);
1776
+ return null;
1777
+ });
1778
+ return { ok: true, data: { ...started, ...(opened ?? { document }) } };
1779
+ }
1345
1780
  const session = blenderRuntime();
1346
1781
  switch (cmd.type) {
1347
- case 'blender-start': {
1348
- // THE OPEN MODEL DOCUMENT'S `.blend` WINS over the default: a models
1349
- // project's document is its own `src/models/<name>.blend`, and a
1350
- // `blender-start` that named none is asking for the session this tab
1351
- // is showing, not for a second file beside it.
1352
- // After a script's Save As to a file no Model lists, the session this tab
1353
- // shows holds that file (`unlistedFollow`), and a start naming none
1354
- // 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);
1363
- try {
1364
- return { ok: true, data: { ...(await session.start(project!, document)) } };
1365
- } catch (error) {
1366
- // As the document's own open does: a session whose start failed is discarded, so the
1367
- // next `blender-start` makes a new one instead of awaiting the same failure.
1368
- if (session.startFailed && runtime === session) terminateBlenderRuntime();
1369
- throw error;
1370
- }
1371
- }
1372
1782
  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.
1783
+ // Use the in-page script door so every bpy write bumps the shared RNA version. Wire callers receive the original MCP text.
1383
1784
  const answer = await blenderExecute(string(cmd, 'code'));
1384
1785
  return { ok: true, data: { result: answer.text } };
1385
1786
  }
@@ -1404,10 +1805,7 @@ export async function handleBlenderCommand(cmd: {
1404
1805
  }
1405
1806
  case 'blender-rna-set': {
1406
1807
  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.
1808
+ // Use the in-page RNA write door so every write bumps the shared version.
1411
1809
  return {
1412
1810
  ok: true,
1413
1811
  data: {
@@ -1473,30 +1871,11 @@ export async function handleBlenderCommand(cmd: {
1473
1871
  // is a projection of the tree the door already answered, so its actions
1474
1872
  // are pure state (`src/node-view-state.ts`).
1475
1873
  //
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
- }
1874
+ // The same table the `volter.blender-node-view.<verb>` commands run
1875
+ // (`NODE_VIEW_VERBS`): every action is `driveNodeView`'s, and an action it
1876
+ // does not carry is refused by its own default, naming the vocabulary.
1877
+ case 'blender-node-view':
1878
+ return { ok: true, data: { result: driveNodeView(cmd) } };
1500
1879
  case 'blender-outliner-set':
1501
1880
  return {
1502
1881
  ok: true,
@@ -1528,10 +1907,7 @@ export async function handleBlenderCommand(cmd: {
1528
1907
  return { ok: true, data: { ...preview } };
1529
1908
  }
1530
1909
  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.
1910
+ // Transfer bytes in the HTTP body under a caller-minted id; return only the size in the command answer.
1535
1911
  const bytes = await session.readFile(string(cmd, 'path'));
1536
1912
  const transfer = string(cmd, 'transferId');
1537
1913
  const posted = await fetch(`/__editor/blender-file?id=${encodeURIComponent(transfer)}`, {
@@ -1543,9 +1919,32 @@ export async function handleBlenderCommand(cmd: {
1543
1919
  return { ok: false, error: `Transfer ${transfer} was refused: ${posted.status}` };
1544
1920
  return { ok: true, data: { bytes: bytes.length } };
1545
1921
  }
1922
+ // A FILM, RENDERED (`cyclotron render-movie`, a film's Blender shots): started here, answering at once, and
1923
+ // followed until it ends (`renderBlenderMovie`).
1924
+ case 'blender-render-movie': {
1925
+ const optional = (key: string): string | number | undefined => {
1926
+ const value = cmd[key];
1927
+ if (value === undefined || typeof value === 'number' || typeof value === 'string') return value;
1928
+ throw new Error(`${key} is a frame number or a marker name.`);
1929
+ };
1930
+ const width = cmd['width'];
1931
+ const fps = cmd['fps'];
1932
+ const document = cmd['document'];
1933
+ return {
1934
+ ok: true,
1935
+ data: { ...startBlenderMovie({
1936
+ out: string(cmd, 'out'),
1937
+ ...(typeof document === 'string' ? { document } : {}),
1938
+ from: optional('from'), to: optional('to'),
1939
+ ...(typeof width === 'number' ? { width } : {}),
1940
+ ...(typeof fps === 'number' ? { fps } : {}),
1941
+ }) },
1942
+ };
1943
+ }
1944
+ case 'blender-render-status':
1945
+ return { ok: true, data: { ...(await movieRenders.status(string(cmd, 'out'))) } };
1546
1946
  case 'blender-write-file': {
1547
- const binary = atob(string(cmd, 'base64'));
1548
- const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
1947
+ const bytes = bytesFromBase64(string(cmd, 'base64'));
1549
1948
  await session.writeFile(string(cmd, 'path'), bytes);
1550
1949
  return { ok: true, data: { written: bytes.length } };
1551
1950
  }
@@ -1563,23 +1962,7 @@ export async function handleBlenderCommand(cmd: {
1563
1962
  entries,
1564
1963
  session: document.session,
1565
1964
  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.
1965
+ // 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
1966
  ...sessionDocumentPath(document.filepath),
1584
1967
  },
1585
1968
  };