@volter/blender-engine 0.1.57 → 0.1.59

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.
@@ -127,6 +127,7 @@ export interface BlenderEngineOptions {
127
127
  * answered as a whole frame is.
128
128
  */
129
129
  export interface SessionAsk {
130
+ checkpoint?: string;
130
131
  frame?: unknown;
131
132
  hold?: unknown;
132
133
  mesh?: string;
@@ -382,15 +383,16 @@ export function openSessionChannel(
382
383
  /** Answer any `ask` the session has raised, and retire the replies it has
383
384
  * taken. Called from the poll loop, so a present raised inside a call is
384
385
  * served while that call is outstanding. */
385
- async function serveAsks(): Promise<void> {
386
+ async function serveAsks(): Promise<boolean> {
386
387
  let names: string[];
387
388
  try {
388
389
  names = await files.readdir(`${SESSION_ROOT}/ask`);
389
390
  } catch {
390
- return;
391
+ return false;
391
392
  }
392
393
  const raised = new Set(names.filter((name) => name.endsWith('.done')));
393
394
  await retireReplies(raised);
395
+ let progressed = false;
394
396
  for (const name of raised) {
395
397
  const id = name.slice(0, -'.done'.length);
396
398
  if (Number(id) <= asking) continue;
@@ -410,7 +412,9 @@ export function openSessionChannel(
410
412
  files.writeFile(`${SESSION_ROOT}/reply/${id}.done`, '1'),
411
413
  ]);
412
414
  replied.add(id);
415
+ progressed = true;
413
416
  }
417
+ return progressed;
414
418
  }
415
419
 
416
420
  async function request(payload: Record<string, unknown>): Promise<unknown> {
@@ -426,9 +430,9 @@ export function openSessionChannel(
426
430
  files.writeFile(`${SESSION_ROOT}/in/${id}.json`, JSON.stringify(payload)),
427
431
  files.writeFile(`${SESSION_ROOT}/in/${id}.done`, '1'),
428
432
  ]);
429
- const began = performance.now();
433
+ let lastProgress = performance.now();
430
434
  for (;;) {
431
- await serveAsks();
435
+ if (await serveAsks()) lastProgress = performance.now();
432
436
  if (await files.stat(`${SESSION_ROOT}/out/${id}.done`)) {
433
437
  const body = JSON.parse(
434
438
  decoder.decode(await files.readFile(`${SESSION_ROOT}/out/${id}.json`)),
@@ -458,7 +462,7 @@ export function openSessionChannel(
458
462
  'wait on the same missing answer; start a new program with `blender-start {fresh: true}` ' +
459
463
  '(`VOLTER_BLENDER_FRESH_SESSION=1` for the battery harness).',
460
464
  );
461
- const delay = pollDelay(performance.now() - began);
465
+ const delay = pollDelay(performance.now() - lastProgress);
462
466
  await (files.changed ? files.changed(delay) : sleep(delay));
463
467
  }
464
468
  }
@@ -3,6 +3,7 @@ import type { FrameChunk } from './frame-stream.mts';
3
3
  * worker; the model lives in the worker's Python and nowhere else. */
4
4
 
5
5
  export type WorkerRequest =
6
+ | { id: number; op: 'load-next'; token: string }
6
7
  | { id: number; op: 'flush-document' }
7
8
  | { id: number; op: 'history-begin' | 'history-end' }
8
9
  | { id: number; op: 'history-step'; token: string; direction: 'undo' | 'redo' }
@@ -0,0 +1,58 @@
1
+ /** A cooperative job advances only when its owner asks for its next unit.
2
+ * Checkpoints park the producer; they do not renew a timer while work runs. */
3
+ export type PullResult<T> = { load: 'continue'; token: string; phase: string } | { load: 'done'; value: T };
4
+ export class PullJob<T> {
5
+ private started = false;
6
+ private finished = false;
7
+ private sequence = 0;
8
+ private expected: string | null = null;
9
+ private resume: (() => void) | null = null;
10
+ private waiter: { resolve: (value: PullResult<T>) => void; reject: (error: unknown) => void } | null = null;
11
+ constructor(private readonly work: (checkpoint: (phase: string) => Promise<void>) => Promise<T>) {}
12
+ step(token?: string): Promise<PullResult<T>> {
13
+ if (this.finished || this.waiter || (this.started ? token !== this.expected || !this.resume : token !== undefined))
14
+ return Promise.reject(new Error('Invalid or replayed load continuation'));
15
+ const answer = new Promise<PullResult<T>>((resolve, reject) => { this.waiter = { resolve, reject }; });
16
+ if (!this.started) {
17
+ this.started = true;
18
+ void Promise.resolve().then(() => this.work(phase => new Promise<void>(resolve => {
19
+ this.resume = resolve;
20
+ this.expected = `load:${++this.sequence}`;
21
+ const waiter = this.waiter!; this.waiter = null;
22
+ waiter.resolve({ load: 'continue', token: this.expected, phase });
23
+ }))).then(value => {
24
+ this.finished = true;
25
+ const waiter = this.waiter!; this.waiter = null;
26
+ waiter.resolve({ load: 'done', value });
27
+ }, error => {
28
+ this.finished = true;
29
+ const waiter = this.waiter!; this.waiter = null;
30
+ waiter.reject(error);
31
+ });
32
+ } else {
33
+ const resume = this.resume!; this.resume = null; this.expected = null; resume();
34
+ }
35
+ return answer;
36
+ }
37
+ }
38
+
39
+ /** A streamed import consumes at most one MiB before yielding its producer. */
40
+ export function checkpointStream(body: ReadableStream<Uint8Array>, checkpoint: () => Promise<void>): ReadableStream<Uint8Array> {
41
+ const reader = body.getReader();
42
+ let held: Uint8Array | undefined, offset = 0;
43
+ return new ReadableStream<Uint8Array>({
44
+ async pull(controller) {
45
+ try {
46
+ if (!held || offset === held.length) {
47
+ const next = await reader.read();
48
+ if (next.done) { reader.releaseLock(); controller.close(); return; }
49
+ held = next.value; offset = 0;
50
+ }
51
+ await checkpoint();
52
+ const end = Math.min(held.length, offset + 1024 * 1024);
53
+ controller.enqueue(held.subarray(offset, end)); offset = end;
54
+ } catch (error) { await reader.cancel(error).catch(() => {}); controller.error(error); }
55
+ },
56
+ async cancel(reason) { await reader.cancel(reason); reader.releaseLock(); },
57
+ }, { highWaterMark: 0 });
58
+ }
@@ -1,3 +1,4 @@
1
+ import type { PullResult } from './pull-job.mts';
1
2
  import { FrameStreamReader } from './frame-stream.mts';
2
3
  /**
3
4
  * The tab-side handle on a Blender session: spawns the worker, forwards the
@@ -253,7 +254,7 @@ export class BlenderRuntime {
253
254
  );
254
255
  this.#project = project;
255
256
  this.#document ??= document ?? 'models/model.blend';
256
- this.#started ??= this.#request({
257
+ this.#started ??= this.#load({
257
258
  op: 'start',
258
259
  project,
259
260
  ...(document === undefined ? {} : { document }),
@@ -261,6 +262,14 @@ export class BlenderRuntime {
261
262
  return this.#started;
262
263
  }
263
264
 
265
+ async #load(request: Extract<Request, { op: 'start' }>): Promise<RuntimeStart> {
266
+ let result = await this.#request(request) as PullResult<RuntimeStart>;
267
+ while (result?.load === 'continue') {
268
+ result = await this.#request({ op: 'load-next', token: result.token }) as PullResult<RuntimeStart>;
269
+ }
270
+ return result?.load === 'done' ? result.value : result as unknown as RuntimeStart;
271
+ }
272
+
264
273
  #ready(): Promise<RuntimeStart> {
265
274
  if (this.#project === null)
266
275
  return Promise.reject(
@@ -1402,7 +1402,11 @@ class Session:
1402
1402
  options["defer"] = True
1403
1403
  if hasattr(_blender_web, "memory_reset_peak"):
1404
1404
  _blender_web.memory_reset_peak()
1405
- exported = _blender_web.export_frame(json.dumps(options))
1405
+ if hasattr(_blender_web, "export_frame_chunked"):
1406
+ exported = _blender_web.export_frame_chunked(json.dumps(options),
1407
+ lambda: _asked({"checkpoint": "native-export"}))
1408
+ else:
1409
+ exported = _blender_web.export_frame(json.dumps(options))
1406
1410
  _mark("export:door")
1407
1411
  frame = json.loads(exported)
1408
1412
  del exported
@@ -1847,9 +1851,18 @@ class Session:
1847
1851
  self.document = os.path.join(self.project, relative_path)
1848
1852
  if not os.path.exists(self.document):
1849
1853
  return {"document": relative_path, "opened": False}
1854
+ _asked({"checkpoint": "document-open"})
1850
1855
  _mark("open:before")
1851
- bpy.ops.wm.open_mainfile(filepath=self.document)
1856
+ checkpointed_read = hasattr(_blender_web, "set_read_checkpoint")
1857
+ if checkpointed_read:
1858
+ _blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
1859
+ try:
1860
+ bpy.ops.wm.open_mainfile(filepath=self.document)
1861
+ finally:
1862
+ if checkpointed_read:
1863
+ _blender_web.set_read_checkpoint(None)
1852
1864
  _mark("open:after")
1865
+ _asked({"checkpoint": "document-opened"})
1853
1866
  size = os.path.getsize(self.document)
1854
1867
  # THE STAGED COPY GOES ONCE BLENDER HAS READ IT. It sits in the engine's heap (WasmFS keeps
1855
1868
  # file data in linear memory) and nothing reads it again: the load took everything into
package/browser/worker.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { PullJob, checkpointStream } from './pull-job.mts';
1
2
  import { sendFrameValue } from './frame-stream.mts';
2
3
  /**
3
4
  * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
@@ -69,7 +70,8 @@ const pendingPresents = new Map<
69
70
  >();
70
71
 
71
72
  async function streamToTab(value: unknown): Promise<PresentAnswer> {
72
- return await sendFrameValue(value, chunk => {
73
+ return await sendFrameValue(value, async chunk => {
74
+ await loadCheckpoint?.("frame-transfer");
73
75
  const id = ++presentId;
74
76
  return new Promise<PresentAnswer>((resolve, reject) => {
75
77
  pendingPresents.set(id, { resolve, reject });
@@ -85,6 +87,8 @@ function presentToTab(frame: unknown, description: unknown, capture?: CaptureReq
85
87
  }
86
88
 
87
89
  let engine: BlenderEngine | null = null;
90
+ let loadJob: PullJob<unknown> | null = null;
91
+ let loadCheckpoint: ((phase: string) => Promise<void>) | null = null;
88
92
 
89
93
  // ---- The session's document.
90
94
  //
@@ -263,7 +267,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
263
267
  const started = await startBlenderEngine({
264
268
  project,
265
269
  log,
266
- ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue }) => {
270
+ ask: async ({ frame, hold, mesh, image, piece, present, capture, saveDue, checkpoint }) => {
271
+ if (typeof checkpoint === 'string') { await loadCheckpoint?.(checkpoint); return {}; }
267
272
  if (!holder.engine) throw new Error('The Blender session presented before it started');
268
273
  // Save once after the whole command, never during a partial frame.
269
274
  if (saveDue && documentPath !== null) setDocumentDirty(true);
@@ -337,12 +342,14 @@ async function startBlender(project: string, document?: string): Promise<unknown
337
342
  },
338
343
  });
339
344
  holder.engine = started;
345
+ await loadCheckpoint?.("engine-ready");
340
346
  engine = started;
341
347
  // THE PROJECT'S FILES BEFORE THE SESSION'S FIRST ACT, because that act may
342
348
  // be `open_mainfile` on the document — which lives on the host's disk and is
343
349
  // not in the engine's filesystem until it is staged. Every other call stages
344
350
  // on its way in (`execute`); start had nothing to open before it did.
345
351
  if (document) await stageProjectFiles(started.files, project);
352
+ await loadCheckpoint?.("project-imported");
346
353
  const banner = (await started.request({
347
354
  op: 'start',
348
355
  project,
@@ -526,7 +533,7 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
526
533
  const dir = path.slice(0, path.lastIndexOf('/'));
527
534
  if (dir) await files_.mkdirTree(dir);
528
535
  if (files_.writeFileStream && answer.body) {
529
- await files_.writeFileStream(path, answer.body, file.size);
536
+ await files_.writeFileStream(path, loadCheckpoint ? checkpointStream(answer.body, () => loadCheckpoint!("file-import")) : answer.body, file.size);
530
537
  } else {
531
538
  await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
532
539
  }
@@ -567,8 +574,19 @@ async function listSessionFiles(files: BlenderFiles, root: string): Promise<File
567
574
 
568
575
  async function handle(request: WorkerRequest): Promise<unknown> {
569
576
  switch (request.op) {
570
- case 'start':
571
- return start(request.project, request.document);
577
+ case 'start': {
578
+ if (loadJob) throw new Error('The Blender load has already started');
579
+ loadJob = new PullJob(async checkpoint => {
580
+ loadCheckpoint = checkpoint;
581
+ const began = performance.now();
582
+ try { return await start(request.project, request.document); }
583
+ finally { loadCheckpoint = null; log('log', `@@VOLTER-LOAD totalMs=${Math.round(performance.now() - began)}`); }
584
+ });
585
+ return loadJob.step();
586
+ }
587
+ case 'load-next':
588
+ if (!loadJob) throw new Error('No Blender load to continue');
589
+ return loadJob.step(request.token);
572
590
  case 'present-result': {
573
591
  const pending = pendingPresents.get(request.id);
574
592
  pendingPresents.delete(request.id);
@@ -582,6 +600,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
582
600
  return undefined;
583
601
  }
584
602
  }
603
+ if (loadCheckpoint) throw new Error('The Blender load must finish before another command');
585
604
  if (!session || !projectRoot || !engine) throw new Error('The Blender session has not started');
586
605
  const files = engine.files;
587
606
  /** The session channel itself, for the requests whose answer is already the
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.57",
5
+ "version": "0.1.59",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
package/wasm/BUNDLE.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "blender": "5.2.0 LTS",
3
- "source": "54e4af619a285c6d76c56ef960dc99d45aca5084",
3
+ "source": "9fdd05db03bf0e4aa1028607be3e113be7f98d01",
4
4
  "upstream": "fbe6228777e7d9afefcd61a413844e790ae75db7",
5
5
  "sourceRepository": "https://github.com/volter-ai/blender",
6
- "sourceRecipe": "https://github.com/volter-ai/blender/blob/54e4af619a285c6d76c56ef960dc99d45aca5084/build_files/emscripten/RELEASE.md",
6
+ "sourceRecipe": "https://github.com/volter-ai/blender/blob/9fdd05db03bf0e4aa1028607be3e113be7f98d01/build_files/emscripten/RELEASE.md",
7
7
  "factoryEngine": "BLENDER_EEVEE",
8
8
  "files": {
9
9
  "blender_browser.js": "raw",
@@ -16,8 +16,8 @@
16
16
  "sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
17
17
  },
18
18
  "blender_browser.wasm": {
19
- "bytes": 86332914,
20
- "sha256": "15c4b7072ebc655418849da79a6fefed8bff26774e7446ade8d3a54bd6ad7a61"
19
+ "bytes": 86337036,
20
+ "sha256": "9d8cb5bd0dbb34fa7f04858fb6bc831195d0b06e8b82bb26889f3c6d807a9588"
21
21
  },
22
22
  "blender_browser.data": {
23
23
  "bytes": 52698568,
@@ -30,8 +30,8 @@
30
30
  "sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
31
31
  },
32
32
  "blender_browser.wasm.br": {
33
- "bytes": 18601239,
34
- "sha256": "8cd29a87fcce046c292c52cc62523fe0ebbd1db8f05b19503d56311ae12c84c5"
33
+ "bytes": 18604996,
34
+ "sha256": "782c1f8519851e8b901d5309d239429ecd07c4ed374adaf117214f9ac22f84c9"
35
35
  },
36
36
  "blender_browser.data.br": {
37
37
  "bytes": 8056563,
@@ -45,6 +45,6 @@
45
45
  "note": "Stoneguard c7/c14/c15 engine. Emscripten 6.0.5, Release -O3 objects and -O2 link. Portable native arm64 matrix reciprocal plus fused generic OpenSubdiv stencil and patch accumulation; bodice four-stage comparison 28/28 arrays exact. Full-scene comparison and memory scope recorded in provenance/stoneguard-memory.json. Historical battery absent; no full-battery or hosted acceptance claim.",
46
46
  "callTrampoline": "CPython 3.13.13 is compiled with PY_CALL_TRAMPOLINE for Blender CPython slot arity casts. This applies to CPython dispatch, not arbitrary indirect calls.",
47
47
  "engineIds": "The existing session registers Python render engines under CYCLES, BLENDER_EEVEE and BLENDER_WORKBENCH; the headless build permits built-in IDs to be shadowed.",
48
- "exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed.",
48
+ "exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation. Native evaluated corner triangles accompany corner normals. Consumed local conversion stencils are released before later UV channels are constructed. Caller-thread file-read, evaluated-object and subdivision checkpoints provide backpressure for resumable startup. Shared geometry revisions advance once per frame. Existing synchronous export remains available.",
49
49
  "preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
50
50
  }
Binary file