@volter/blender-engine 0.1.0

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 (48) hide show
  1. package/LICENSE +724 -0
  2. package/README.md +48 -0
  3. package/browser/blender-emscripten-engine.mts +289 -0
  4. package/browser/blender-engine.mts +412 -0
  5. package/browser/blender-wali-engine.mts +362 -0
  6. package/browser/index.ts +7 -0
  7. package/browser/protocol.ts +202 -0
  8. package/browser/rna.ts +697 -0
  9. package/browser/runtime.ts +511 -0
  10. package/browser/session-frame.mts +169 -0
  11. package/browser/session.py +4166 -0
  12. package/browser/three/agx-base-srgb.lut +0 -0
  13. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  14. package/browser/three/agx-look-punchy.lut +0 -0
  15. package/browser/three/attach-presenter.ts +140 -0
  16. package/browser/three/blender-agx.ts +235 -0
  17. package/browser/three/blender-base64.ts +42 -0
  18. package/browser/three/blender-corner-normals.ts +432 -0
  19. package/browser/three/blender-display-lut.ts +145 -0
  20. package/browser/three/blender-filmic.ts +49 -0
  21. package/browser/three/blender-frame-columns.ts +100 -0
  22. package/browser/three/blender-gradient-texture.ts +57 -0
  23. package/browser/three/blender-runtime-armature.ts +528 -0
  24. package/browser/three/blender-runtime-frame.ts +39 -0
  25. package/browser/three/blender-runtime-geometry.ts +342 -0
  26. package/browser/three/blender-runtime-lighting.ts +829 -0
  27. package/browser/three/blender-runtime-shadows.ts +107 -0
  28. package/browser/three/blender-runtime-view.ts +1481 -0
  29. package/browser/three/blender-runtime-volume.ts +128 -0
  30. package/browser/three/blender-runtime-weights.ts +306 -0
  31. package/browser/three/blender-sky.ts +461 -0
  32. package/browser/three/blender-standard.ts +68 -0
  33. package/browser/three/blender-triangulate.ts +181 -0
  34. package/browser/three/filmic-srgb.lut +0 -0
  35. package/browser/three/presenter.ts +265 -0
  36. package/browser/three/release.ts +27 -0
  37. package/browser/three/sky-precompute-worker.ts +45 -0
  38. package/browser/three/sky-worker.ts +79 -0
  39. package/browser/three/world-field-sampler.ts +358 -0
  40. package/browser/three/world-math.ts +59 -0
  41. package/browser/vgai_three.py +554 -0
  42. package/browser/worker.ts +648 -0
  43. package/package.json +48 -0
  44. package/wasm/BUNDLE.json +65 -0
  45. package/wasm/DEPENDENCY-LICENSES.txt +4879 -0
  46. package/wasm/blender_browser.data.br +0 -0
  47. package/wasm/blender_browser.js +2 -0
  48. package/wasm/blender_browser.wasm.br +0 -0
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Headless Blender, running in the editor tab's worker -- THE SEAM, and the
3
+ * choice of which Blender answers it.
4
+ *
5
+ * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
6
+ * 2026-09-17), and since the 2026-09-18 amendment there are TWO BUILDS of it
7
+ * and neither retires the other:
8
+ *
9
+ * emscripten -- the STANDALONE skew. Blender in this worker's own module,
10
+ * `@volter/editor-blender` with zero in-house dependencies. What the
11
+ * package ships under `wasm/`.
12
+ * wali -- the SUBSTRATE skew. The same Blender linked for
13
+ * `wasm32-wali-linux-musl` and run as an ordinary Linux
14
+ * program through `@volter/browser-wali`: argv, stdio, an
15
+ * exit code, syscalls, a filesystem.
16
+ *
17
+ * ONE GATE, BOTH SKEWS: the scene battery passes on each at the same counts,
18
+ * from the same `session.py`. A skew may REFUSE a capability by name; it may
19
+ * not answer the same question differently. So this file is where the two
20
+ * meet and everything above it -- `worker.ts`, `session-frame.mts`,
21
+ * `blender-runtime-host.ts`, the presenter, the battery, the document -- is
22
+ * written once.
23
+ *
24
+ * HOW THE SKEW IS CHOSEN: IT IS NOT. It is READ off the artifact this editor
25
+ * serves, through the door that already exists for "is there a Blender here at
26
+ * all" (`/__editor/blender-wasm/status`, `blender-wasm-artifact.ts`). One
27
+ * editor serves one Blender; the served directory holds either a
28
+ * `blender_browser.js` bundle or a `blender.wasm` beside its `runtime/` tree,
29
+ * and that IS the answer. There is no manifest field, no setting and no flag,
30
+ * because nothing a user does should depend on the skew -- if it did, the
31
+ * skews would be answering differently, which the ruling forbids.
32
+ * `VGAI_BLENDER_WASM_DIR` is the existing door for pointing the editor at a
33
+ * different build and is how the substrate skew is reached in development.
34
+ *
35
+ * WHAT DIFFERS, and it is four things, all below this line:
36
+ * 1. how the module is booted (a `--post-js` glue factory / a WALI program);
37
+ * 2. where the session's `in`/`out`/`ask` files live (`Module.FS` / the
38
+ * program's filesystem);
39
+ * 3. how the export door's arena is read (`HEAPU8` / the file
40
+ * `export_frame`'s `buffer_path` names -- see `readArena`);
41
+ * 4. cross-origin isolation, which only the Emscripten build asks of the page.
42
+ * Everything else, including the request/ask protocol below, is one
43
+ * implementation over {@link BlenderFiles}.
44
+ */
45
+
46
+ export const SESSION_ROOT = '/work/.vgai-session';
47
+ export const SESSION_SCRIPT = `${SESSION_ROOT}/session.py`;
48
+
49
+ /**
50
+ * THE TWO CHANNEL DIRECTORIES THIS PAGE OWNS, and the reason they are a
51
+ * constant instead of two literals per engine.
52
+ *
53
+ * ONE WRITER PER DIRECTORY is the channel's ownership rule (see
54
+ * {@link openSessionChannel}), and a directory's owner is also the side that
55
+ * CREATES it: `in/` and `reply/` are made here, before the program starts;
56
+ * `out/` and `ask/` are made by `session.py`, which owns them. An engine that
57
+ * pre-created all four put a guest `mkdir`/`chmod` on a path the page had
58
+ * already touched, which is the same crossing edit at a smaller scale.
59
+ */
60
+ export const PAGE_OWNED_DIRECTORIES = [`${SESSION_ROOT}/in`, `${SESSION_ROOT}/reply`] as const;
61
+
62
+ /** Which build of Blender this editor serves. */
63
+ export type BlenderSkew = 'emscripten' | 'wali';
64
+
65
+ export interface BlenderFileStat {
66
+ size: number;
67
+ /** The raw mode, S_IFDIR and all: `listModuleFiles` reads the type bits. */
68
+ mode: number;
69
+ mtimeMs: number;
70
+ }
71
+
72
+ /**
73
+ * The session's filesystem, as the one shape everything above the seam uses.
74
+ *
75
+ * ASYNCHRONOUS BECAUSE ONE SKEW CANNOT BE OTHERWISE: on WALI the tree lives in
76
+ * a `BrowserFileSystem` whose bytes are a promise by contract. The Emscripten
77
+ * side answers from `Module.FS` and resolves immediately.
78
+ *
79
+ * `stat` ANSWERS NULL FOR AN ABSENT PATH rather than throwing. The poll loop
80
+ * asks "is the answer there yet" thousands of times per call, and the version
81
+ * of this that threw sorted a genuine failure from an expected absence by
82
+ * matching `/ENOENT|no such|FS error|errno/i` against the message -- a filter
83
+ * that swallows any real error whose text happens to say "errno".
84
+ */
85
+ export interface BlenderFiles {
86
+ readFile(path: string): Promise<Uint8Array>;
87
+ writeFile(path: string, data: string | Uint8Array): Promise<void>;
88
+ mkdirTree(path: string): Promise<void>;
89
+ readdir(path: string): Promise<string[]>;
90
+ /** Null when the path is not there. */
91
+ stat(path: string): Promise<BlenderFileStat | null>;
92
+ unlink(path: string): Promise<void>;
93
+ }
94
+
95
+ export interface BlenderEngineOptions {
96
+ /** The project's absolute path; the engine's filesystem mirrors it there. */
97
+ project: string;
98
+ log(level: 'log' | 'error', text: string): void;
99
+ /** What the session asks the TAB for, mid-call: a frame, and sometimes a
100
+ * photograph of it. Whatever this resolves to is what Python receives.
101
+ *
102
+ * `saveDue` rides BESIDE the frame (the frame's own schema is the
103
+ * presenter's): this present left the session's `.blend` behind the model,
104
+ * and whoever answers owes it a save once the session goes idle. */
105
+ ask(payload: { frame: unknown; capture?: unknown; saveDue?: boolean }): Promise<unknown>;
106
+ }
107
+
108
+ export interface BlenderEngine {
109
+ /** Which build answered. Reported out of `blender-start`, because a number
110
+ * that differs between the skews is a defect in one of them and the board
111
+ * has to be able to say which one it measured. */
112
+ readonly skew: BlenderSkew;
113
+ readonly banner: { blender: string; engines: string[] };
114
+ /** The session's filesystem. */
115
+ readonly files: BlenderFiles;
116
+ /** One request, one answer. Rejects with the session's own error text. */
117
+ request(payload: Record<string, unknown>): Promise<unknown>;
118
+ /**
119
+ * THE LAST FRAME'S EXPORT ARENA, whole, offset zero at the arena's base.
120
+ *
121
+ * The one call whose implementation the skews do not share, and the reason
122
+ * is worth the paragraph. `bpy_web_export.cc` writes every column of a frame
123
+ * into a side arena and the JSON frame names each one `{offset, length,
124
+ * dtype, count, stride}` -- no descriptor of the arena itself, because
125
+ * "natively the path is the caller's own string". On Emscripten the arena is
126
+ * in the module's linear memory and the C exports name it, so this is a view
127
+ * on `HEAPU8` and costs nothing. Under WALI the module's memory is in a
128
+ * worker the host cannot reach, and does not need to: `export_frame`'s
129
+ * `buffer_path` option already writes the same bytes to a file, so this
130
+ * reads that file. `session-frame.mts` is handed the bytes and never learns
131
+ * which it got.
132
+ *
133
+ * VALID UNTIL THE NEXT `export_frame`, on both skews.
134
+ */
135
+ readArena(): Promise<Uint8Array>;
136
+ /** Milliseconds from the first byte of the artifact to the session's ready line. */
137
+ readonly bootMs: number;
138
+ /** The engine's memory in bytes, now. Linear memory on the standalone skew;
139
+ * null where the skew cannot see the module's memory from the host, which
140
+ * is the WALI case and is why `vgai status` prints "unreported" rather than
141
+ * a zero. */
142
+ memoryBytes(): number | null;
143
+ /** Bytes of packed `.data` payload handed back after boot, or null when the
144
+ * build has no packed payload to release (every WALI boot) or no release
145
+ * door. Reported out of `blender-start` because an action whose success is
146
+ * otherwise invisible has to say so somewhere a caller reads. */
147
+ readonly releasedPayloadBytes: number | null;
148
+ }
149
+
150
+ export const ARTIFACT_BASE = '/__editor/blender-wasm';
151
+
152
+ /**
153
+ * An artifact URL, resolved against THE MODULE — never against `self.location`.
154
+ *
155
+ * A worker's `location` is where its SCRIPT was loaded from, and that is not
156
+ * always an http url of this editor: when the page is a frame this editor does
157
+ * not own (the Code-OSS workbench, docs/CODE-OSS.md §Boot, DESKTOP), a
158
+ * cross-origin worker can only be constructed through a same-origin `blob:`
159
+ * doorway, so `self.location.href` reads `blob:vscode-file://vscode-app/<uuid>`
160
+ * — an opaque base against which `new URL('/__editor/...', base)` THROWS
161
+ * `TypeError: Invalid URL`. Measured 2026-09-19: that is what `blender-start`
162
+ * answered on the desktop shape once its message reached the worker at all.
163
+ *
164
+ * `import.meta.url` is this module's own url and is always the http one the
165
+ * editor served, whatever loaded the worker. Every artifact url is built from
166
+ * it, and nothing here reads `self.location`.
167
+ */
168
+ export function artifactUrl(file: string): string {
169
+ return new URL(`${ARTIFACT_BASE}/${file}`, import.meta.url).href;
170
+ }
171
+
172
+ /** What the editor's artifact door answers. `blender-wasm-artifact.ts` owns it. */
173
+ export interface BlenderArtifactStatus {
174
+ available: boolean;
175
+ /** Which build the served directory holds; null when nothing is served. */
176
+ skew: BlenderSkew | null;
177
+ dir: string | null;
178
+ sizes: Record<string, number>;
179
+ encoded: Record<string, 'br'>;
180
+ /** Why it is unavailable, named. Empty when it is available. */
181
+ missing: string[];
182
+ /** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
183
+ * requires for any non-blob URL. */
184
+ integrity?: string;
185
+ /** WALI only: how many workers the program's host pool gets, and how many
186
+ * Blender itself is told to use (`blender -t N`). */
187
+ workers?: { pool: number; blender: number };
188
+ }
189
+
190
+ export async function artifactStatus(): Promise<BlenderArtifactStatus> {
191
+ const answer = await fetch(artifactUrl('status'));
192
+ if (!answer.ok)
193
+ return {
194
+ available: false,
195
+ skew: null,
196
+ dir: null,
197
+ sizes: {},
198
+ encoded: {},
199
+ missing: [`${ARTIFACT_BASE}/status answered ${answer.status}`],
200
+ };
201
+ return (await answer.json()) as BlenderArtifactStatus;
202
+ }
203
+
204
+ /**
205
+ * Boot the Blender this editor serves and hold it as a long-lived session.
206
+ *
207
+ * The two implementations are reached by DYNAMIC IMPORT and that is load
208
+ * bearing, not style: the WALI engine reaches `@volter/browser-wali` and
209
+ * `@volter/browser-runtime`, and the standalone skew must not pay for them.
210
+ * A module never imported is never fetched, and `blender-wali-engine.mts`
211
+ * names no package at all -- it imports the substrate from URLs this same
212
+ * editor serves, exactly as the Emscripten engine fetches its glue. So the
213
+ * standalone skew's dependency on browser-substrate is zero in the package
214
+ * manifest, zero in the module graph, and zero on the wire.
215
+ */
216
+ export async function startBlenderEngine(options: BlenderEngineOptions): Promise<BlenderEngine> {
217
+ const status = await artifactStatus();
218
+ if (!status.available)
219
+ throw new Error(
220
+ `The headless Blender WebAssembly build is not served by this editor: ${status.missing.join('; ')}`,
221
+ );
222
+ if (status.skew === 'wali') {
223
+ const { startWaliBlenderEngine } = await import('./blender-wali-engine.mts');
224
+ return startWaliBlenderEngine(options, status);
225
+ }
226
+ const { startEmscriptenBlenderEngine } = await import('./blender-emscripten-engine.mts');
227
+ return startEmscriptenBlenderEngine(options, status);
228
+ }
229
+
230
+ /** The poll cadence: tight while a call is young, then backing off so a
231
+ * ten-minute bake does not spend ten minutes of main-thread wakeups. */
232
+ function pollDelay(elapsedMs: number): number {
233
+ if (elapsedMs < 200) return 1;
234
+ if (elapsedMs < 5_000) return 5;
235
+ return 25;
236
+ }
237
+
238
+ export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
239
+
240
+ const decoder = new TextDecoder();
241
+
242
+ /**
243
+ * THE DIRECTORY CHANNEL, written once for both skews.
244
+ *
245
+ * `session.py`'s header states the other half and says why it is a directory
246
+ * rather than stdin. Nothing here is toolchain-specific: a request is
247
+ * `in/<id>.json` then `in/<id>.done`, an answer is `out/<id>.json` +
248
+ * `out/<id>.done`, and an `ask` the session raises mid-call is served from
249
+ * inside this poll loop -- which is why `saveDocument` cannot be raised from
250
+ * Python's idle loop (`worker.ts` holds that clock).
251
+ *
252
+ * ONE WRITER PER DIRECTORY. This is the channel's single structural rule and
253
+ * it is what keeps the WALI skew alive for a whole session:
254
+ *
255
+ * in/ the PAGE writes and the PAGE unlinks; the guest only reads.
256
+ * out/ the GUEST writes and the GUEST unlinks; the page only reads.
257
+ * ask/ the GUEST writes and the GUEST unlinks; the page only reads.
258
+ * reply/ the PAGE writes and the PAGE unlinks; the guest only reads.
259
+ *
260
+ * WHY IT IS STRUCTURAL AND NOT A TUNING. Under WALI the two sides are two
261
+ * snapshot-backed views of one tree, reconciled by patches: the guest sends
262
+ * the paths IT changed, and `applyProcessFilesystemPatch` refuses the patch
263
+ * (`assertPatchBase`) when a path in it no longer holds what the guest's base
264
+ * said -- after which the host TERMINATES the program's worker. A path can
265
+ * only disagree if somebody other than the patch's author moved it. So with
266
+ * the sets of paths the two sides mutate DISJOINT, the assertion has nothing
267
+ * left to fire on. MEASURED 2026-09-19 (WS-X): with this page unlinking four
268
+ * files in the guest's own `out/` after every answer, a render loop died at
269
+ * call 37 (`base`: a file, `current`: null) and at call 14 (a different
270
+ * mtime), and narrowing the window moved the death 1 -> 13 -> 37 without
271
+ * closing it.
272
+ *
273
+ * THE ACK IS THE REQUEST FILE'S DISAPPEARANCE, so it costs no file and no
274
+ * extra write. Each side needs to know when the other has taken what it left:
275
+ *
276
+ * - this page unlinks `in/<id>.json` and then `in/<id>.done` ONLY after it
277
+ * has read `out/<id>.json`. So `in/<id>.done` being gone IS "the page
278
+ * took the answer" -- a fact the guest reads out of a directory it never
279
+ * writes to, from the `listdir` its loop already does. It then removes
280
+ * its own `out/<id>.*`.
281
+ * - the session unlinks `ask/<id>.json` and then `ask/<id>.done` only after
282
+ * it has read `reply/<id>.json`, so `ask/<id>.done` being gone is "the
283
+ * guest took the reply" -- read here out of the `readdir` `serveAsks`
284
+ * already does, after which this page removes its own `reply/<id>.*`.
285
+ *
286
+ * `.done` IS ALWAYS UNLINKED LAST, on both sides, because `.done` is the
287
+ * token the other side watches: a `.done` without its `.json` would be a
288
+ * half-visible message.
289
+ *
290
+ * NOTHING HERE CATCHES ITS OWN UNLINK. These are files this page wrote into a
291
+ * directory nothing else writes to; an ENOENT would mean the ownership rule
292
+ * had been broken, and the loud failure of the call that found it is the
293
+ * proof the rule holds.
294
+ */
295
+ export function openSessionChannel(
296
+ files: BlenderFiles,
297
+ options: BlenderEngineOptions,
298
+ /**
299
+ * WHY THE POLL LOOP NEEDS A LIVENESS PREDICATE, and it is the difference
300
+ * between a board and a wasted afternoon.
301
+ *
302
+ * The loop below waits for `out/<id>.done` and nothing else. When the
303
+ * program behind the directory DIES, that file is never written, so the
304
+ * call waits forever: `vgai status` reports it IN FLIGHT for as long as
305
+ * anyone looks, `blender-start` keeps answering from the host's cached
306
+ * banner, and the harness has no answer to time out against. MEASURED
307
+ * 2026-09-19 three times on the WALI skew -- 507 s, 621 s and 964 s of a
308
+ * battery model spent waiting on a Blender that had already ended with
309
+ * `[object WebAssembly.Exception]`, each ending in a killed process and no
310
+ * result record at all.
311
+ *
312
+ * This returns null while the program is alive and the reason it ended
313
+ * otherwise. A caller that supplies one turns that infinite wait into a
314
+ * named failure naming the recovery door. The standalone skew supplies
315
+ * none: its module is the worker, so a death there ends the worker rather
316
+ * than stranding a reader, and inventing a predicate for a failure nobody
317
+ * has measured would be a fiction.
318
+ */
319
+ programEnded?: () => string | null,
320
+ ): {
321
+ request(payload: Record<string, unknown>): Promise<unknown>;
322
+ } {
323
+ let sequence = 0;
324
+ let asking = 0;
325
+ /** Asks this page has answered whose `reply/<id>.*` it still owes a cleanup
326
+ * -- cleared as each `ask/<id>.done` disappears. */
327
+ const replied = new Set<string>();
328
+
329
+ /** THE GUEST'S ACK: its own `ask/<id>.done` is gone, so it has read the
330
+ * reply and this page may retire the two files IT wrote. `.done` last. */
331
+ async function retireReplies(raised: ReadonlySet<string>): Promise<void> {
332
+ for (const id of replied) {
333
+ if (raised.has(`${id}.done`)) continue;
334
+ await files.unlink(`${SESSION_ROOT}/reply/${id}.json`);
335
+ await files.unlink(`${SESSION_ROOT}/reply/${id}.done`);
336
+ replied.delete(id);
337
+ }
338
+ }
339
+
340
+ /** Answer any `ask` the session has raised, and retire the replies it has
341
+ * taken. Called from the poll loop, so a present raised inside a call is
342
+ * served while that call is outstanding. */
343
+ async function serveAsks(): Promise<void> {
344
+ let names: string[];
345
+ try {
346
+ names = await files.readdir(`${SESSION_ROOT}/ask`);
347
+ } catch {
348
+ return;
349
+ }
350
+ const raised = new Set(names.filter((name) => name.endsWith('.done')));
351
+ await retireReplies(raised);
352
+ for (const name of raised) {
353
+ const id = name.slice(0, -'.done'.length);
354
+ if (Number(id) <= asking) continue;
355
+ asking = Number(id);
356
+ const payload = JSON.parse(
357
+ decoder.decode(await files.readFile(`${SESSION_ROOT}/ask/${id}.json`)),
358
+ ) as { frame: unknown; capture?: unknown; saveDue?: boolean };
359
+ let answer: unknown;
360
+ try {
361
+ answer = await options.ask(payload);
362
+ } catch (error) {
363
+ answer = { error: error instanceof Error ? error.message : String(error) };
364
+ }
365
+ await files.writeFile(`${SESSION_ROOT}/reply/${id}.json`, JSON.stringify(answer ?? null));
366
+ await files.writeFile(`${SESSION_ROOT}/reply/${id}.done`, '1');
367
+ replied.add(id);
368
+ }
369
+ }
370
+
371
+ async function request(payload: Record<string, unknown>): Promise<unknown> {
372
+ const id = String(++sequence);
373
+ await files.writeFile(`${SESSION_ROOT}/in/${id}.json`, JSON.stringify(payload));
374
+ await files.writeFile(`${SESSION_ROOT}/in/${id}.done`, '1');
375
+ const began = performance.now();
376
+ for (;;) {
377
+ await serveAsks();
378
+ if (await files.stat(`${SESSION_ROOT}/out/${id}.done`)) {
379
+ const body = JSON.parse(
380
+ decoder.decode(await files.readFile(`${SESSION_ROOT}/out/${id}.json`)),
381
+ ) as { result?: unknown; error?: string };
382
+ // THE PAGE UNLINKS WHAT THE PAGE WROTE, AND ONLY THAT. `out/<id>.*`
383
+ // is the session's to remove; this loop does not touch it. Doing so
384
+ // is what killed a WALI session mid-run (see the ownership rule
385
+ // above): the guest's patch for `out/<id>.done` crossed this page's
386
+ // unlink of it, `assertPatchBase` refused the patch, and the host
387
+ // terminated the program's worker -- no guest exit path ran, which is
388
+ // why every instrument on `SYS_exit` was silent through it.
389
+ //
390
+ // These two unlinks are also the ACK. The session watches for
391
+ // `in/<id>.done` to vanish and takes that as "the page has the
392
+ // answer", so `.done` goes last and neither is attempted before
393
+ // `out/<id>.json` has been read.
394
+ await files.unlink(`${SESSION_ROOT}/in/${id}.json`);
395
+ await files.unlink(`${SESSION_ROOT}/in/${id}.done`);
396
+ if (body.error) throw new Error(body.error);
397
+ return body.result;
398
+ }
399
+ const ended = programEnded?.();
400
+ if (ended)
401
+ throw new Error(
402
+ `Blender is gone, so this call will never be answered: ${ended}. ` +
403
+ 'The session keeps its directory and its cached banner, so every later call would ' +
404
+ 'wait on the same missing answer; start a new program with `blender-start {fresh: true}` ' +
405
+ '(`VGAI_BLENDER_FRESH_SESSION=1` for the battery harness).',
406
+ );
407
+ await sleep(pollDelay(performance.now() - began));
408
+ }
409
+ }
410
+
411
+ return { request };
412
+ }