@volter/blender-engine 0.1.20 → 0.1.22

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.
@@ -37,6 +37,7 @@ import {
37
37
  PAGE_OWNED_DIRECTORIES,
38
38
  SESSION_ROOT,
39
39
  SESSION_SCRIPT,
40
+ sessionLevel,
40
41
  sleep,
41
42
  } from './blender-engine.mts';
42
43
  import sessionPython from './session.py?raw';
@@ -195,10 +196,7 @@ export async function startEmscriptenBlenderEngine(
195
196
  // session's OWN named conditions go there.
196
197
  const say = (_level: 'log' | 'error', text: string) => {
197
198
  if (text.startsWith('@@VOLTER-READY ')) readyLine = text.slice('@@VOLTER-READY '.length);
198
- options.log(
199
- text.startsWith('@@VOLTER-WARN') || text.startsWith('@@VOLTER-ERROR') ? 'error' : 'log',
200
- text,
201
- );
199
+ options.log(sessionLevel(text), text);
202
200
  };
203
201
  const module = await factory({
204
202
  arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
@@ -107,7 +107,7 @@ export interface BlenderFiles {
107
107
  export interface BlenderEngineOptions {
108
108
  /** The project's absolute path; the engine's filesystem mirrors it there. */
109
109
  project: string;
110
- log(level: 'log' | 'error', text: string): void;
110
+ log(level: 'log' | 'warn' | 'error', text: string): void;
111
111
  /** What the session asks the TAB for, mid-call: a frame, and sometimes a
112
112
  * photograph of it. Whatever this resolves to is what Python receives.
113
113
  *
@@ -161,6 +161,14 @@ export interface BlenderEngine {
161
161
 
162
162
  export const ARTIFACT_BASE = '/__editor/blender-wasm';
163
163
 
164
+ /** The console level of one line the session printed. Only the session's own named conditions
165
+ * reach the editor's console, each at the severity it names; Blender's streams stay page output. */
166
+ export function sessionLevel(line: string): 'log' | 'warn' | 'error' {
167
+ if (line.startsWith('@@VOLTER-ERROR')) return 'error';
168
+ if (line.startsWith('@@VOLTER-WARN')) return 'warn';
169
+ return 'log';
170
+ }
171
+
164
172
  /**
165
173
  * An artifact URL, resolved against THE MODULE — never against `self.location`.
166
174
  *
@@ -52,6 +52,7 @@ import {
52
52
  PAGE_OWNED_DIRECTORIES,
53
53
  SESSION_ROOT,
54
54
  SESSION_SCRIPT,
55
+ sessionLevel,
55
56
  sleep,
56
57
  } from './blender-engine.mts';
57
58
  import sessionPython from './session.py?raw';
@@ -231,6 +232,110 @@ async function sharedRuntime(
231
232
  return layer;
232
233
  }
233
234
 
235
+ type StoreLayerFromIndex = (
236
+ root: string,
237
+ files: RuntimeIndex['files'],
238
+ store: string,
239
+ options: { mtimeMs: number; include?: (path: string) => boolean },
240
+ ) => unknown;
241
+
242
+ /**
243
+ * Blender's runtime tree as a STORE ON THE ORIGIN'S DISK the program reads
244
+ * (browser-substrate ADR-0048): installed once per version by the
245
+ * substrate's installer worker, and read file by file as Blender opens them,
246
+ * a large file by the ranges it reads. The shared layer before it copied all
247
+ * 96 MB into memory before Blender started (0.9 s of each boot) and held it
248
+ * for the run; a boot reads 268 of the 2,082 files (measured 2026-09-28).
249
+ * Undefined, and said, where it cannot be installed: the shared layer then
250
+ * carries the tree as before.
251
+ */
252
+ async function installedRuntime(
253
+ fromIndex: StoreLayerFromIndex,
254
+ hold: ((projectId: string) => Promise<void>) | undefined,
255
+ log: BlenderEngineOptions['log'],
256
+ ): Promise<unknown> {
257
+ const began = performance.now();
258
+ const indexAnswer = await fetch(artifactUrl('runtime.idx'));
259
+ if (!indexAnswer.ok)
260
+ throw new Error(`${ARTIFACT_BASE}/runtime.idx answered ${indexAnswer.status}`);
261
+ const text = await indexAnswer.text();
262
+ const index = JSON.parse(text) as RuntimeIndex;
263
+ // The tree's version is its index: the same files at the same sizes and modes, in the same order,
264
+ // and its newest file's time (`runtimeTag`'s validator), so a file rebuilt at the same size is a new version.
265
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)));
266
+ const tag = [...digest.slice(0, 12)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
267
+ const projectId = `${RUNTIME_STORE_FAMILY}${tag}`;
268
+ // THIS PAGE HOLDS ITS TREE IN USE from before it is installed to the end of the session, so
269
+ // another tab installing another version never removes it under this one's runs (ADR-0048).
270
+ if (hold) {
271
+ try {
272
+ await hold(projectId);
273
+ } catch (error) {
274
+ log('log', `blender: the runtime tree stays in memory: it cannot be held in use (${error instanceof Error ? error.message : String(error)})`);
275
+ return undefined;
276
+ }
277
+ }
278
+ let worker: Worker;
279
+ try {
280
+ worker = new Worker(artifactUrl('wali/browser-wali/tree-store-worker.js'), { type: 'module' });
281
+ } catch (error) {
282
+ log('log', `blender: the runtime tree stays in memory: no installer worker (${error instanceof Error ? error.message : String(error)})`);
283
+ return undefined;
284
+ }
285
+ // A BOUNDED STALL, NOT A BOUNDED INSTALL: an install stalled in another tab (a throttled background
286
+ // tab) holds this tree's installer lock, or the network stops. Each batch written restarts the wait,
287
+ // so a slow link still installs. On a stall this boot carries the tree in memory, and the installer
288
+ // is left to finish for the next boot rather than stopped halfway.
289
+ const answer = await new Promise<{ ok: boolean; installed?: boolean; bytes?: number; error?: string }>((resolve) => {
290
+ let settled = false;
291
+ const settle = (value: { ok: boolean; installed?: boolean; bytes?: number; error?: string }): void => {
292
+ if (!settled) { settled = true; resolve(value); }
293
+ };
294
+ let timer = setTimeout(() => settle({ ok: false, error: `no progress in ${INSTALL_STALL_MS / 1000} s` }), INSTALL_STALL_MS);
295
+ worker.onmessage = (event) => {
296
+ clearTimeout(timer);
297
+ if (typeof event.data?.progress === 'number') {
298
+ if (!settled) timer = setTimeout(() => settle({ ok: false, error: `no progress in ${INSTALL_STALL_MS / 1000} s` }), INSTALL_STALL_MS);
299
+ return;
300
+ }
301
+ worker.terminate();
302
+ settle(event.data);
303
+ };
304
+ worker.onerror = (event) => {
305
+ clearTimeout(timer);
306
+ worker.terminate();
307
+ settle({ ok: false, error: event.message || 'the installer worker failed to start' });
308
+ };
309
+ worker.postMessage({
310
+ projectId,
311
+ family: RUNTIME_STORE_FAMILY,
312
+ root: RESOURCES,
313
+ files: index.files,
314
+ blobUrl: new URL(artifactUrl('runtime.bin'), location.href).href,
315
+ mtimeMs: Date.now(),
316
+ });
317
+ });
318
+ if (!answer.ok) {
319
+ log('log', `blender: the runtime tree stays in memory: it could not be installed (${answer.error})`);
320
+ return undefined;
321
+ }
322
+ const layer = fromIndex(RESOURCES, index.files, projectId, {
323
+ mtimeMs: Date.now(),
324
+ include: (path) => !PACK_RENDER_OVERRIDES.has(path),
325
+ });
326
+ log('log', `blender: Blender's ${index.files.length}-file runtime tree is on disk (${answer.installed ? `installed, ${Math.round((answer.bytes ?? 0) / 1e6)} MB` : 'installed before'}) in ${Math.round(performance.now() - began)} ms`);
327
+ return layer;
328
+ }
329
+
330
+ /**
331
+ * Installed runtime trees' store names begin so; installing one removes the others that no tab
332
+ * reads. `host-tree.` is the substrate's mark of an installed tree (`TREE_STORE_PREFIX`), which no
333
+ * project may take.
334
+ */
335
+ const RUNTIME_STORE_FAMILY = 'host-tree.blender-runtime-';
336
+ /** How long a boot waits on an installer that writes nothing before it carries the tree in memory. */
337
+ const INSTALL_STALL_MS = 20_000;
338
+
234
339
  /** The program's filesystem, as {@link BlenderFiles}. */
235
340
  function programFiles(filesystem: BrowserFileSystemLike): BlenderFiles {
236
341
  // One listener for the engine's life, and one waiter per wait: a change ends
@@ -316,7 +421,10 @@ export async function startWaliBlenderEngine(
316
421
  ])
317
422
  filesystem.mkdirSync(directory, { recursive: true });
318
423
  const fromIndex = layers['sharedFileLayerFromIndex'] as LayerFromIndex | undefined;
319
- const runtimeLayer = fromIndex ? await sharedRuntime(fromIndex, options.log) : undefined;
424
+ const fromStore = layers['storeFileLayerFromIndex'] as StoreLayerFromIndex | undefined;
425
+ const holdTree = layers['holdTreeStore'] as ((projectId: string) => Promise<void>) | undefined;
426
+ const runtimeLayer = (fromStore ? await installedRuntime(fromStore, holdTree, options.log) : undefined)
427
+ ?? (fromIndex ? await sharedRuntime(fromIndex, options.log) : undefined);
320
428
  if (!runtimeLayer) await stageRuntime(filesystem, options.log);
321
429
  filesystem.writeFileSync(SESSION_SCRIPT, sessionPython);
322
430
 
@@ -381,10 +489,7 @@ export async function startWaliBlenderEngine(
381
489
  // -- the same rule as the standalone engine, and for the same measured
382
490
  // reason: only the session's own named conditions belong in the set an
383
491
  // agent must drive to zero.
384
- options.log(
385
- line.startsWith('@@VOLTER-WARN') || line.startsWith('@@VOLTER-ERROR') ? 'error' : 'log',
386
- line,
387
- );
492
+ options.log(sessionLevel(line), line);
388
493
  }
389
494
  };
390
495
  // THE PROGRAM DOES NOT RETURN. `session.py` loops forever, so `run` settles
@@ -109,7 +109,7 @@ export type WorkerReply =
109
109
  * as the record of what the session submitted — the second side a
110
110
  * displayed-versus-submitted comparison otherwise does not have. */
111
111
  | { op: 'present'; id: number; frame: unknown; description: unknown; capture?: CaptureRequest }
112
- | { op: 'log'; level: 'log' | 'error'; text: string }
112
+ | { op: 'log'; level: 'log' | 'warn' | 'error'; text: string }
113
113
  /**
114
114
  * HOW BIG THE MODULE'S LINEAR MEMORY IS, posted after every call.
115
115
  *
@@ -44,7 +44,7 @@ export interface BlenderRuntimeOptions {
44
44
  description: unknown,
45
45
  capture?: CaptureRequest,
46
46
  ): Promise<PresentAnswer> | PresentAnswer;
47
- log?(level: 'log' | 'error', text: string): void;
47
+ log?(level: 'log' | 'warn' | 'error', text: string): void;
48
48
  }
49
49
 
50
50
  /**
package/browser/worker.ts CHANGED
@@ -41,7 +41,7 @@ import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './pr
41
41
  import { columnsToTypedArrays, describeFrame } from './session-frame.mts';
42
42
 
43
43
  const post = (reply: WorkerReply) => (self as unknown as Worker).postMessage(reply);
44
- const log = (level: 'log' | 'error', text: string) => post({ op: 'log', level, text });
44
+ const log = (level: 'log' | 'warn' | 'error', text: string) => post({ op: 'log', level, text });
45
45
 
46
46
  interface Session {
47
47
  start(project: string): Promise<unknown>;
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.20",
5
+ "version": "0.1.22",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },