@volter/blender-engine 0.1.15 → 0.1.17

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.
@@ -112,7 +112,8 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
112
112
  const FS = module.FS;
113
113
  return {
114
114
  readFile: async (path) => FS.readFile(path),
115
- writeFile: async (path, data) => FS.writeFile(path, data),
115
+ // Not `async`: a write that throws throws here, so `request` never starts `.done` after a failed `.json`.
116
+ writeFile: (path, data) => { FS.writeFile(path, data); return Promise.resolve(); },
116
117
  mkdirTree: async (path) => FS.mkdirTree(path),
117
118
  readdir: async (path) => FS.readdir(path),
118
119
  stat: async (path) => {
@@ -90,6 +90,18 @@ export interface BlenderFiles {
90
90
  /** Null when the path is not there. */
91
91
  stat(path: string): Promise<BlenderFileStat | null>;
92
92
  unlink(path: string): Promise<void>;
93
+ /**
94
+ * Resolves at the next change to this filesystem or after `within` ms,
95
+ * whichever is first, when the engine can say so. The call loop waits on it
96
+ * in place of its poll's sleep, so an answer is read when it lands rather
97
+ * than at the next timer: a browser clamps a chain of `setTimeout(1)` to
98
+ * 4 ms, which was most of a no-op call's return leg (measured 2026-09-28:
99
+ * 6-7 ms of 8-10). Each wait is its own and is gone when it resolves; racing
100
+ * one shared promise against every poll's sleep kept a reaction per poll
101
+ * until the next change (review of d36c8004: 43 MB in an hour-long call).
102
+ * Without it the loop sleeps as before.
103
+ */
104
+ changed?(within: number): Promise<void>;
93
105
  }
94
106
 
95
107
  export interface BlenderEngineOptions {
@@ -364,16 +376,28 @@ export function openSessionChannel(
364
376
  } catch (error) {
365
377
  answer = { error: error instanceof Error ? error.message : String(error) };
366
378
  }
367
- await files.writeFile(`${SESSION_ROOT}/reply/${id}.json`, JSON.stringify(answer ?? null));
368
- await files.writeFile(`${SESSION_ROOT}/reply/${id}.done`, '1');
379
+ // One turn, `.done` second, as a request's two files (see `request`).
380
+ await Promise.all([
381
+ files.writeFile(`${SESSION_ROOT}/reply/${id}.json`, JSON.stringify(answer ?? null)),
382
+ files.writeFile(`${SESSION_ROOT}/reply/${id}.done`, '1'),
383
+ ]);
369
384
  replied.add(id);
370
385
  }
371
386
  }
372
387
 
373
388
  async function request(payload: Record<string, unknown>): Promise<unknown> {
374
389
  const id = String(++sequence);
375
- await files.writeFile(`${SESSION_ROOT}/in/${id}.json`, JSON.stringify(payload));
376
- await files.writeFile(`${SESSION_ROOT}/in/${id}.done`, '1');
390
+ // BOTH WRITES BEGIN IN ONE TURN, `.done` second. Both engines write
391
+ // synchronously inside `writeFile` and throw from it, so the order is kept
392
+ // and a failed `.json` never leaves a `.done` behind; and the WALI
393
+ // engine carries one turn's writes to the program as one patch, where an
394
+ // await between them sent the request as two imports in series, the
395
+ // second waiting for the first's acknowledgement (measured 2026-09-28:
396
+ // most of a no-op call's 1.4-1.9 ms inbound leg).
397
+ await Promise.all([
398
+ files.writeFile(`${SESSION_ROOT}/in/${id}.json`, JSON.stringify(payload)),
399
+ files.writeFile(`${SESSION_ROOT}/in/${id}.done`, '1'),
400
+ ]);
377
401
  const began = performance.now();
378
402
  for (;;) {
379
403
  await serveAsks();
@@ -406,7 +430,8 @@ export function openSessionChannel(
406
430
  'wait on the same missing answer; start a new program with `blender-start {fresh: true}` ' +
407
431
  '(`VOLTER_BLENDER_FRESH_SESSION=1` for the battery harness).',
408
432
  );
409
- await sleep(pollDelay(performance.now() - began));
433
+ const delay = pollDelay(performance.now() - began);
434
+ await (files.changed ? files.changed(delay) : sleep(delay));
410
435
  }
411
436
  }
412
437
 
@@ -63,6 +63,10 @@ const RESOURCES = '/bw';
63
63
  * session's own root, because it belongs to the session's lifetime. */
64
64
  const ARENA_PATH = `${SESSION_ROOT}/frame.bin`;
65
65
 
66
+ /** The WALI pack's startup copies of the render override, by runtime-relative
67
+ * path; `stageRuntime` leaves them out. */
68
+ const PACK_RENDER_OVERRIDES = new Set(['scripts/startup/vgai_three.py', 'scripts/startup/volter_three.py']);
69
+
66
70
  /** The host filesystem seam, in the only shape this file uses. */
67
71
  interface BrowserFileSystemLike {
68
72
  existsSync(path: string): boolean;
@@ -72,6 +76,8 @@ interface BrowserFileSystemLike {
72
76
  mkdirSync(path: string, options?: { recursive?: boolean }): void;
73
77
  readdirSync(path: string): string[];
74
78
  unlinkSync(path: string): void;
79
+ /** A write or removal, the program's included (its patches land here). */
80
+ on?(event: 'change' | 'delete', listener: (path: string) => void): unknown;
75
81
  }
76
82
 
77
83
  interface WaliProgram {
@@ -142,6 +148,17 @@ async function stageRuntime(
142
148
  `Blender runtime ${entry.path}: bytes ${offset}..${offset + entry.size} lie outside ` +
143
149
  `the ${blob.byteLength}-byte runtime blob`,
144
150
  );
151
+ // THE PACK'S RENDER OVERRIDE IS NOT STAGED. It is the delivery for a bare
152
+ // `blender -b` with no session: at startup it asks a presenter at its own
153
+ // directory and waits two seconds for one. This session registers the same
154
+ // override itself (`session.py`, `VolterRenderEngine`) and answers through
155
+ // its own channel, so here the module could only wait out those two
156
+ // seconds (measured 2026-09-27: every boot printed "no presenter answered
157
+ // at /tmp/vgai-presenter within 2 s").
158
+ if (PACK_RENDER_OVERRIDES.has(entry.path)) {
159
+ offset += entry.size;
160
+ continue;
161
+ }
145
162
  const path = `${RESOURCES}/${entry.path}`;
146
163
  filesystem.mkdirSync(path.slice(0, path.lastIndexOf('/')), { recursive: true });
147
164
  filesystem.writeFileSync(path, blob.subarray(offset, offset + entry.size));
@@ -156,11 +173,82 @@ async function stageRuntime(
156
173
  return { files: index.files.length, bytes: blob.byteLength, ms };
157
174
  }
158
175
 
176
+ type LayerFromIndex = (
177
+ root: string,
178
+ files: RuntimeIndex['files'],
179
+ bytes: SharedArrayBuffer,
180
+ options: { mtimeMs: number; include?: (path: string) => boolean },
181
+ ) => unknown;
182
+
183
+ /**
184
+ * Blender's runtime tree as a SHARED LAYER the program reads, never written
185
+ * into this page's filesystem (browser-substrate ADR-0044 §6): the blob is
186
+ * read once, straight into shared memory, and the index names where each file
187
+ * lies in it. Staged file by file, the tree was held twice -- in this page's
188
+ * filesystem and in the layer the substrate made of it for the program's
189
+ * workers -- and took 0.3-0.5 s of every boot (measured 2026-09-27). A
190
+ * substrate without the door (`sharedFileLayerFromIndex`) stages as before.
191
+ */
192
+ async function sharedRuntime(
193
+ fromIndex: LayerFromIndex,
194
+ log: BlenderEngineOptions['log'],
195
+ ): Promise<unknown> {
196
+ const began = performance.now();
197
+ const indexAnswer = await fetch(artifactUrl('runtime.idx'));
198
+ if (!indexAnswer.ok)
199
+ throw new Error(`${ARTIFACT_BASE}/runtime.idx answered ${indexAnswer.status}`);
200
+ const index = (await indexAnswer.json()) as RuntimeIndex;
201
+ const total = index.files.reduce((sum, file) => sum + file.size, 0);
202
+ const blobAnswer = await fetch(artifactUrl('runtime.bin'));
203
+ if (!blobAnswer.ok || !blobAnswer.body)
204
+ throw new Error(`${ARTIFACT_BASE}/runtime.bin answered ${blobAnswer.status}`);
205
+ const shared = new SharedArrayBuffer(total);
206
+ const bytes = new Uint8Array(shared);
207
+ let filled = 0;
208
+ const reader = blobAnswer.body.getReader();
209
+ for (;;) {
210
+ const { done, value } = await reader.read();
211
+ if (done) break;
212
+ // The index and the blob must agree to the byte, or Python reads truncated files.
213
+ if (filled + value.byteLength > total)
214
+ throw new Error(`Blender runtime blob is longer than the ${total} bytes its index accounts for`);
215
+ bytes.set(value, filled);
216
+ filled += value.byteLength;
217
+ }
218
+ if (filled !== total)
219
+ throw new Error(`Blender runtime index accounts for ${total} bytes; the blob served ${filled}`);
220
+ const layer = fromIndex(RESOURCES, index.files, shared, {
221
+ mtimeMs: Date.now(),
222
+ // THE PACK'S RENDER OVERRIDE IS LEFT OUT: it is the delivery for a bare
223
+ // `blender -b` with no session, and at startup it asks a presenter at its
224
+ // own directory and waits two seconds for one. This session registers the
225
+ // same override itself (`session.py`, `VolterRenderEngine`) and answers
226
+ // through its own channel, so here the module could only wait (measured
227
+ // 2026-09-27: every boot printed "no presenter answered ... within 2 s").
228
+ include: (path) => !PACK_RENDER_OVERRIDES.has(path),
229
+ });
230
+ log('log', `blender: shared Blender's ${index.files.length}-file runtime tree in ${Math.round(performance.now() - began)} ms`);
231
+ return layer;
232
+ }
233
+
159
234
  /** The program's filesystem, as {@link BlenderFiles}. */
160
235
  function programFiles(filesystem: BrowserFileSystemLike): BlenderFiles {
236
+ // One listener for the engine's life, and one waiter per wait: a change ends
237
+ // every wait in progress, a wait's own timeout ends only it, and either way
238
+ // it is forgotten.
239
+ const waiting = new Set<() => void>();
240
+ const wake = (): void => { for (const done of [...waiting]) done(); };
241
+ filesystem.on?.('change', wake);
242
+ filesystem.on?.('delete', wake);
161
243
  return {
244
+ ...(filesystem.on ? { changed: (within: number) => new Promise<void>((resolve) => {
245
+ const done = (): void => { clearTimeout(timer); waiting.delete(done); resolve(); };
246
+ const timer = setTimeout(done, within);
247
+ waiting.add(done);
248
+ }) } : {}),
162
249
  readFile: (path) => filesystem.readFile(path),
163
- writeFile: async (path, data) => filesystem.writeFileSync(path, data),
250
+ // Not `async`: a write that throws throws here, so `request` never starts `.done` after a failed `.json`.
251
+ writeFile: (path, data) => { filesystem.writeFileSync(path, data); return Promise.resolve(); },
164
252
  mkdirTree: async (path) => filesystem.mkdirSync(path, { recursive: true }),
165
253
  readdir: async (path) => filesystem.readdirSync(path),
166
254
  stat: async (path) => {
@@ -183,9 +271,10 @@ export async function startWaliBlenderEngine(
183
271
  status: BlenderArtifactStatus,
184
272
  ): Promise<BlenderEngine> {
185
273
  const started = performance.now();
186
- const [wali, runtime] = await Promise.all([
274
+ const [wali, runtime, layers] = await Promise.all([
187
275
  substrate('browser-wali/worker-program.js'),
188
276
  substrate('browser-runtime/browser-memory-filesystem.js'),
277
+ substrate('browser-wali/shared-file-layer.js').catch((): Record<string, unknown> => ({})),
189
278
  ]);
190
279
  const WaliWorkerProgram = wali['BrowserWaliWorkerProgram'] as
191
280
  | (new (
@@ -203,6 +292,15 @@ export async function startWaliBlenderEngine(
203
292
  'does not know.',
204
293
  );
205
294
 
295
+ // THE MODULE LOADS WHILE THE RUNTIME LAYER IS BUILT. Blender's 93 MB module
296
+ // was fetched and compiled by the program's worker only once the program
297
+ // ran, after the layer below: measured 2026-09-28, the layer 270-975 ms and
298
+ // then the module 200-250 ms warm, in series. The substrate's preload fills
299
+ // the cache the program's worker reads; a substrate without it, or a preload
300
+ // that fails, leaves the worker to load the module as before.
301
+ const preload = wali['preloadWaliModule'] as ((url: string, integrity: string) => Promise<void>) | undefined;
302
+ if (preload && status.integrity) void preload(artifactUrl('blender.wasm'), status.integrity).catch(() => undefined);
303
+
206
304
  const filesystem = new MemoryFileSystem();
207
305
  // `out` and `ask` are deliberately absent: `session.py` owns those two and
208
306
  // makes them itself (the channel's ownership rule in `blender-engine.mts`).
@@ -217,7 +315,9 @@ export async function startWaliBlenderEngine(
217
315
  options.project,
218
316
  ])
219
317
  filesystem.mkdirSync(directory, { recursive: true });
220
- await stageRuntime(filesystem, options.log);
318
+ const fromIndex = layers['sharedFileLayerFromIndex'] as LayerFromIndex | undefined;
319
+ const runtimeLayer = fromIndex ? await sharedRuntime(fromIndex, options.log) : undefined;
320
+ if (!runtimeLayer) await stageRuntime(filesystem, options.log);
221
321
  filesystem.writeFileSync(SESSION_SCRIPT, sessionPython);
222
322
 
223
323
  const workers = status.workers ?? { pool: 16, blender: 4 };
@@ -248,7 +348,23 @@ export async function startWaliBlenderEngine(
248
348
  VOLTER_SESSION_ROOT: SESSION_ROOT,
249
349
  // THE ARENA'S DOOR ON THIS SKEW. See `readArena`.
250
350
  VOLTER_EXPORT_BUFFER_PATH: ARENA_PATH,
351
+ // OPENIMAGEIO'S POOL AT ONE THREAD, as Blender's own are (`-t 1`,
352
+ // BLENDER_WALI_WORKERS). Its default pool is built at the CPU count
353
+ // before `-t` reaches it: measured 2026-09-28, nine of the ten threads
354
+ // this Blender started were OpenImageIO's, idle for the session, each
355
+ // holding a pool worker (browser-substrate `programs/blender/5.2.0`
356
+ // README §(f)).
357
+ OPENIMAGEIO_THREADS: '1',
251
358
  },
359
+ ...(runtimeLayer ? { layers: [runtimeLayer] } : {}),
360
+ // BLENDER'S /tmp IS ITS OWN, as it is in the standalone skew's WasmFS:
361
+ // what it writes there never crosses to this page, so nothing waits for
362
+ // the page to take it (browser-substrate `privateRoots`, ADR-0046).
363
+ // Measured 2026-09-27: published, a `.blend` saved to /tmp cost 11 ms
364
+ // against the standalone skew's 2, most of it the page taking the file
365
+ // before Blender's next printed line. The session's channel and the
366
+ // project live elsewhere and still cross.
367
+ privateRoots: ['/tmp'],
252
368
  threadPoolSize: workers.pool,
253
369
  // Blender talks to nothing. The editor's own routes are this worker's, not
254
370
  // the program's.
@@ -1827,6 +1827,19 @@ def _drop(path):
1827
1827
  _say("@@VOLTER-WARN the session could not remove its own %s: %r" % (path, error))
1828
1828
 
1829
1829
 
1830
+ # HOW OFTEN TO LOOK, by how recently something happened. A present's answer
1831
+ # and the next call of a gesture are usually a few milliseconds away, and a
1832
+ # fixed 2 ms sleep put a whole sleep into each of them: a no-op execute spent
1833
+ # 6-7 ms of its 8-10 ms coming back, most of it the present's `ask` waiting out
1834
+ # sleeps on both skews (measured 2026-09-28). Brisk for 50 ms after activity,
1835
+ # then the old 2 ms, so an idle session lists its directory no more than before.
1836
+ _BRISK_SECONDS = 0.05
1837
+
1838
+
1839
+ def _pause(since):
1840
+ time.sleep(0.0002 if time.monotonic() - since < _BRISK_SECONDS else 0.002)
1841
+
1842
+
1830
1843
  def ask(payload):
1831
1844
  """Block until the tab answers. The worker's own thread is free while this
1832
1845
  waits; only the Blender pthread is held, which is where the operator is.
@@ -1843,8 +1856,9 @@ def ask(payload):
1843
1856
  with open(os.path.join(ASK, name + ".done"), "w") as fh:
1844
1857
  fh.write("1")
1845
1858
  marker = os.path.join(REPLY, name + ".done")
1859
+ began = time.monotonic()
1846
1860
  while not os.path.exists(marker):
1847
- time.sleep(0.002)
1861
+ _pause(began)
1848
1862
  with open(os.path.join(REPLY, name + ".json")) as fh:
1849
1863
  body = fh.read()
1850
1864
  _drop(os.path.join(ASK, name + ".json"))
@@ -4994,6 +5008,7 @@ def _channel_loop():
4994
5008
  # with the session.
4995
5009
  seen = set()
4996
5010
  unacked = []
5011
+ active = 0.0
4997
5012
  while True:
4998
5013
  # A FAILED LISTING IS NOT AN EMPTY ONE. This swallowed every OSError
4999
5014
  # into "no requests", so the one failure that matters -- the process
@@ -5029,7 +5044,7 @@ def _channel_loop():
5029
5044
  names = sorted((n for n in markers if n not in seen),
5030
5045
  key=lambda n: int(n[: -len(".done")]))
5031
5046
  if not names:
5032
- time.sleep(0.002)
5047
+ _pause(active)
5033
5048
  continue
5034
5049
  for marker in names:
5035
5050
  seen.add(marker)
@@ -5047,6 +5062,8 @@ def _channel_loop():
5047
5062
  with open(os.path.join(OUT, rid + ".done"), "w") as fh:
5048
5063
  fh.write("1")
5049
5064
  unacked.append(rid)
5065
+ # From the last answer: the page's next call of a gesture follows it.
5066
+ active = time.monotonic()
5050
5067
 
5051
5068
 
5052
5069
  try:
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.15",
5
+ "version": "0.1.17",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },