@volter/blender-engine 0.1.61 → 0.1.63

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.
@@ -27,6 +27,7 @@
27
27
  * `session.py` chmods its own root as its first act.
28
28
  */
29
29
 
30
+ import { checkpointStream } from './pull-job.mts';
30
31
  import { writeStreamedFile, type StreamFileSystem } from './stream-file.mts';
31
32
  import {
32
33
  artifactUrl,
@@ -149,14 +150,17 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
149
150
  */
150
151
  const ARTIFACT_CACHE = 'volter-blender-artifacts';
151
152
 
152
- async function cachedArtifact(file: string, digest: string | undefined): Promise<Response> {
153
+ async function cachedArtifact(file: string, digest: string | undefined, checkpoint: () => Promise<void>): Promise<Response> {
154
+ const bounded = (response: Response): Response => response.ok && response.body
155
+ ? new Response(checkpointStream(response.body, checkpoint), { status: response.status, headers: response.headers })
156
+ : response;
153
157
  const url = artifactUrl(file);
154
- if (!digest || typeof caches === 'undefined') return fetch(url);
158
+ if (!digest || typeof caches === 'undefined') return bounded(await fetch(url));
155
159
  const key = `${url}?sha256=${digest}`;
156
160
  const cache = await caches.open(ARTIFACT_CACHE);
157
161
  const hit = await cache.match(key);
158
- if (hit) return hit;
159
- const response = await fetch(url);
162
+ if (hit) return bounded(hit);
163
+ const response = bounded(await fetch(url));
160
164
  if (!response.ok) return response;
161
165
  // One build's bytes per file: an older build's are dropped as this one lands.
162
166
  for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
@@ -177,15 +181,18 @@ export async function startEmscriptenBlenderEngine(
177
181
  );
178
182
  const glueUrl = artifactUrl('blender_browser.js');
179
183
  const digests = status.digests ?? {};
184
+ const checkpoint = async (phase: string): Promise<void> => { await options.ask({ checkpoint: phase }); };
185
+ const artifact = (file: string) => cachedArtifact(file, digests[file], () => checkpoint(`artifact/${file}`));
180
186
  // The `.data` package is handed to the glue whole (`getPreloadedPackage`),
181
187
  // so it is read before the module starts; the wasm streams in beside it.
182
188
  const [factory, preloaded] = await Promise.all([
183
189
  loadFactory(glueUrl),
184
- cachedArtifact('blender_browser.data', digests['blender_browser.data']).then(async (response) => {
190
+ artifact('blender_browser.data').then(async (response) => {
185
191
  if (!response.ok) throw new Error(`blender_browser.data: HTTP ${response.status}`);
186
192
  return response.arrayBuffer();
187
193
  }),
188
194
  ]);
195
+ await checkpoint('runtime-package-ready');
189
196
  let bootError: unknown = null;
190
197
  const started = performance.now();
191
198
  let readyLine: string | null = null;
@@ -221,9 +228,10 @@ export async function startEmscriptenBlenderEngine(
221
228
  getPreloadedPackage: () => preloaded,
222
229
  instantiateWasm: (imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void) => {
223
230
  void (async () => {
224
- const response = await cachedArtifact('blender_browser.wasm', digests['blender_browser.wasm']);
231
+ const response = await artifact('blender_browser.wasm');
225
232
  if (!response.ok) throw new Error(`blender_browser.wasm: HTTP ${response.status}`);
226
233
  const { instance, module: compiled } = await WebAssembly.instantiateStreaming(response, imports);
234
+ await checkpoint('wasm-instantiated');
227
235
  receive(instance, compiled);
228
236
  })().catch((error: unknown) => { bootError = error; });
229
237
  return {};
@@ -273,6 +281,7 @@ export async function startEmscriptenBlenderEngine(
273
281
  }).finally(() => {
274
282
  (WebAssembly as { Memory: unknown }).Memory = Memory;
275
283
  });
284
+ await checkpoint('runtime-initialized');
276
285
  // `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
277
286
  // long before the session exists. The ready line is what says it does.
278
287
  while (readyLine === null) {
@@ -337,7 +346,8 @@ export async function startEmscriptenBlenderEngine(
337
346
 
338
347
  const files = moduleFiles(module);
339
348
  FS.chmod('/bw/datafiles', 0o755);
340
- await mountEssentials(files, digests['essentials.bin']);
349
+ await checkpoint('python-ready');
350
+ await mountEssentials(files, digests['essentials.bin'], checkpoint);
341
351
  const { request } = openSessionChannel(files, options);
342
352
 
343
353
  return {
@@ -364,9 +374,9 @@ export async function startEmscriptenBlenderEngine(
364
374
  /** Assets are data, not a second engine. Ship them separately so a data update
365
375
  * does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
366
376
  * checked before anything enters Blender's filesystem. */
367
- async function mountEssentials(files: BlenderFiles, digest: string | undefined): Promise<void> {
377
+ async function mountEssentials(files: BlenderFiles, digest: string | undefined, checkpoint: (phase: string) => Promise<void>): Promise<void> {
368
378
  const [indexResponse, payloadResponse] = await Promise.all([
369
- fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest),
379
+ fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest, () => checkpoint('artifact/essentials.bin')),
370
380
  ]);
371
381
  if (!indexResponse.ok || !payloadResponse.ok)
372
382
  throw new Error(`Blender Essentials assets are missing (${indexResponse.status}/${payloadResponse.status})`);
@@ -380,6 +390,7 @@ async function mountEssentials(files: BlenderFiles, digest: string | undefined):
380
390
  if (payload.byteLength !== index.bytes || hash !== index.sha256)
381
391
  throw new Error('Blender Essentials payload does not match its source manifest');
382
392
  for (const file of index.files) {
393
+ await checkpoint('essentials-file');
383
394
  if (!file.path || file.path.includes('\\') || file.path.split('/').some(p => !p || p === '.' || p === '..') ||
384
395
  !Number.isInteger(file.offset) || !Number.isInteger(file.bytes) ||
385
396
  file.offset < 0 || file.bytes < 0 || file.offset + file.bytes > payload.byteLength)
@@ -258,6 +258,7 @@ export async function artifactStatus(): Promise<BlenderArtifactStatus> {
258
258
  */
259
259
  export async function startBlenderEngine(options: BlenderEngineOptions): Promise<BlenderEngine> {
260
260
  const status = await artifactStatus();
261
+ await options.ask({ checkpoint: 'artifact-status' });
261
262
  if (!status.available)
262
263
  throw new Error(
263
264
  `The headless Blender WebAssembly build is not served by this editor: ${status.missing.join('; ')}`,
@@ -1,7 +1,9 @@
1
1
  /** A cooperative job advances only when its owner asks for its next unit.
2
2
  * Checkpoints park the producer; they do not renew a timer while work runs. */
3
3
  export type PullResult<T> = { load: 'continue'; token: string; phase: string } | { load: 'done'; value: T };
4
+ let nextJob = 0;
4
5
  export class PullJob<T> {
6
+ private readonly job = ++nextJob;
5
7
  private started = false;
6
8
  private finished = false;
7
9
  private sequence = 0;
@@ -17,7 +19,7 @@ export class PullJob<T> {
17
19
  this.started = true;
18
20
  void Promise.resolve().then(() => this.work(phase => new Promise<void>(resolve => {
19
21
  this.resume = resolve;
20
- this.expected = `load:${++this.sequence}`;
22
+ this.expected = `load:${this.job}:${++this.sequence}`;
21
23
  const waiter = this.waiter!; this.waiter = null;
22
24
  waiter.resolve({ load: 'continue', token: this.expected, phase });
23
25
  }))).then(value => {
@@ -159,7 +159,7 @@ export class BlenderRuntime {
159
159
  #terminated = false;
160
160
  #dirty = false;
161
161
  readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
162
- if (!this.#dirty && this.#pending.size === 0) return;
162
+ if (!this.#dirty && this.#acceptedRequests === 0) return;
163
163
  event.preventDefault();
164
164
  event.returnValue = '';
165
165
  };
@@ -219,6 +219,8 @@ export class BlenderRuntime {
219
219
  for (const id of [...this.#pending.keys()]) this.#settled(id);
220
220
  for (const pending of this.#pending.values()) pending.reject(error);
221
221
  this.#pending.clear();
222
+ // Queued logical operations must reject too; this worker cannot answer them.
223
+ this.terminate();
222
224
  };
223
225
  }
224
226
 
@@ -259,7 +261,7 @@ export class BlenderRuntime {
259
261
  );
260
262
  this.#project = project;
261
263
  this.#document ??= document ?? 'models/model.blend';
262
- this.#started ??= this.#load({
264
+ this.#started ??= this.#request({
263
265
  op: 'start',
264
266
  project,
265
267
  ...(document === undefined ? {} : { document }),
@@ -267,15 +269,6 @@ export class BlenderRuntime {
267
269
  return this.#started;
268
270
  }
269
271
 
270
- async #load(request: Extract<Request, { op: 'start' }>): Promise<RuntimeStart> {
271
- let result = await this.#request(request) as PullResult<RuntimeStart>;
272
- while (result?.load === 'continue') {
273
- this.#loadBoundary = result.phase;
274
- result = await this.#request({ op: 'load-next', token: result.token }) as PullResult<RuntimeStart>;
275
- }
276
- return result?.load === 'done' ? result.value : result as unknown as RuntimeStart;
277
- }
278
-
279
272
  #ready(): Promise<RuntimeStart> {
280
273
  if (this.#project === null)
281
274
  return Promise.reject(
@@ -566,9 +559,32 @@ export class BlenderRuntime {
566
559
  this.#lastCallWindow = { start: started, end: started + elapsed };
567
560
  }
568
561
 
562
+ #acceptedRequests = 0;
563
+ #requestTail: Promise<void> = Promise.resolve();
569
564
  #request(request: Request, shutdown = false): Promise<unknown> {
570
565
  if (this.#terminated || (this.#stopping && !shutdown))
571
566
  return Promise.reject(new Error('The Blender session is stopping or terminated'));
567
+ const began = performance.now();
568
+ this.#acceptedRequests++;
569
+ const answer = this.#requestTail.then(async () => {
570
+ this.#loadBoundary = request.op;
571
+ let result = await this.#wireRequest(request) as PullResult<unknown>;
572
+ while (result?.load === 'continue') {
573
+ this.#loadBoundary = result.phase;
574
+ result = await this.#wireRequest({ op: 'load-next', token: result.token }) as PullResult<unknown>;
575
+ }
576
+ return result?.load === 'done' ? result.value : result;
577
+ }).finally(() => {
578
+ this.#acceptedRequests--;
579
+ const totalMs = Math.round(performance.now() - began);
580
+ try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${request.op} totalMs=${totalMs}`); } catch { /* diagnostics cannot poison the queue */ }
581
+ });
582
+ this.#requestTail = answer.then(() => {}, () => {});
583
+ return answer;
584
+ }
585
+
586
+ #wireRequest(request: Request): Promise<unknown> {
587
+ if (this.#terminated) return Promise.reject(new Error('The Blender session is terminated'));
572
588
  const id = ++this.#nextId;
573
589
  return new Promise((resolve, reject) => {
574
590
  this.#pending.set(id, { resolve, reject });
@@ -4421,8 +4421,11 @@ def rna_rig(object_name=None):
4421
4421
  subjects = [obj for obj in bpy.context.view_layer.objects
4422
4422
  if obj.type == "MESH" and obj.data is not None
4423
4423
  and obj.find_armature() is not None]
4424
- return {"frame": scene_frame, "named": object_name,
4425
- "rigs": [_rig_of(obj) for obj in subjects]}
4424
+ rigs = []
4425
+ for obj in subjects:
4426
+ _asked({"checkpoint": "rig-object"})
4427
+ rigs.append(_rig_of(obj))
4428
+ return {"frame": scene_frame, "named": object_name, "rigs": rigs}
4426
4429
 
4427
4430
 
4428
4431
  def _rig_of(obj):
@@ -4506,7 +4509,11 @@ def _rig_of(obj):
4506
4509
  truncated = 0
4507
4510
  unweighted = 0
4508
4511
  most = 0
4512
+ checkpoint_at = time.monotonic()
4509
4513
  for i, vertex in enumerate(mesh.vertices):
4514
+ if i % 1024 == 0 and time.monotonic() - checkpoint_at >= 0.05:
4515
+ _asked({"checkpoint": "rig-weights"})
4516
+ checkpoint_at = time.monotonic()
4510
4517
  pairs = []
4511
4518
  for element in vertex.groups:
4512
4519
  bone = group_to_bone.get(int(element.group))
package/browser/worker.ts CHANGED
@@ -573,17 +573,22 @@ async function listSessionFiles(files: BlenderFiles, root: string): Promise<File
573
573
  return out;
574
574
  }
575
575
 
576
+ function pullWork(label: string, work: () => Promise<unknown>): Promise<unknown> {
577
+ if (loadCheckpoint) throw new Error('The current Blender operation must finish first');
578
+ loadJob = new PullJob(async checkpoint => {
579
+ loadCheckpoint = checkpoint;
580
+ const began = performance.now();
581
+ try { return await work(); }
582
+ finally { loadCheckpoint = null; log('log', `@@VOLTER-WORK op=${label} totalMs=${Math.round(performance.now() - began)}`); }
583
+ });
584
+ return loadJob.step();
585
+ }
586
+
576
587
  async function handle(request: WorkerRequest): Promise<unknown> {
577
588
  switch (request.op) {
578
589
  case 'start': {
579
590
  if (loadJob) throw new Error('The Blender load has already started');
580
- loadJob = new PullJob(async checkpoint => {
581
- loadCheckpoint = checkpoint;
582
- const began = performance.now();
583
- try { return await start(request.project, request.document); }
584
- finally { loadCheckpoint = null; log('log', `@@VOLTER-LOAD totalMs=${Math.round(performance.now() - began)}`); }
585
- });
586
- return loadJob.step();
591
+ return pullWork('start', () => start(request.project, request.document));
587
592
  }
588
593
  case 'load-next':
589
594
  if (!loadJob) throw new Error('No Blender load to continue');
@@ -632,7 +637,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
632
637
  case 'present':
633
638
  // Straight through to `session.py`'s own `present` op — the worker adds
634
639
  // nothing, and a capture-less present answers `{ presented, revision }`.
635
- return ask({ op: 'present' });
640
+ return pullWork('present', () => ask({ op: 'present' }));
636
641
  case 'scene-info':
637
642
  return session.sceneInfo();
638
643
  case 'object-info':
@@ -698,10 +703,10 @@ async function handle(request: WorkerRequest): Promise<unknown> {
698
703
  // because `collection` crossed `runtime.ts` and `session.py` and never
699
704
  // this switch, with no error anywhere.
700
705
  case 'rig':
701
- return ask({
706
+ return pullWork('rig', () => ask({
702
707
  op: 'rig',
703
708
  ...(request.object === undefined ? {} : { object: request.object }),
704
- });
709
+ }));
705
710
  case 'action-clip':
706
711
  return ask({
707
712
  op: 'action-clip',
@@ -791,7 +796,7 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
791
796
  async function answerRequest(request: WorkerRequest): Promise<void> {
792
797
  try {
793
798
  const result = await handle(request);
794
- if (documentDirty) await saveDocument();
799
+ if (!loadCheckpoint && documentDirty) await saveDocument();
795
800
  await reportHistory();
796
801
  post({ id: request.id, result });
797
802
  } catch (error) {
@@ -811,7 +816,7 @@ async function answerRequest(request: WorkerRequest): Promise<void> {
811
816
  }
812
817
 
813
818
  async function reportHistory(): Promise<void> {
814
- if (!engine || !session) return;
819
+ if (loadCheckpoint || !engine || !session) return;
815
820
  const entries = await engine.request({ op: 'history-events' }) as import('./protocol').NativeHistoryEntry[];
816
821
  if (entries.length) post({ op: 'history', entries });
817
822
  }
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.61",
5
+ "version": "0.1.63",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },