@volter/blender-engine 0.1.50 → 0.1.52

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 { writeStreamedFile, type StreamFileSystem } from './stream-file.mts';
30
31
  import {
31
32
  artifactUrl,
32
33
  type BlenderArtifactStatus,
@@ -44,7 +45,7 @@ import sessionPython from './session.py?raw';
44
45
 
45
46
  /** What the module factory is, in the only shape this file uses. */
46
47
  interface BlenderModule {
47
- FS: {
48
+ FS: StreamFileSystem & {
48
49
  readFile(path: string): Uint8Array;
49
50
  writeFile(path: string, data: string | Uint8Array): void;
50
51
  mkdirTree(path: string): void;
@@ -115,6 +116,7 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
115
116
  readFile: async (path) => FS.readFile(path),
116
117
  // Not `async`: a write that throws throws here, so `request` never starts `.done` after a failed `.json`.
117
118
  writeFile: (path, data) => { FS.writeFile(path, data); return Promise.resolve(); },
119
+ writeFileStream: (path, body, size) => writeStreamedFile(FS, path, body, size),
118
120
  mkdirTree: async (path) => FS.mkdirTree(path),
119
121
  readdir: async (path) => FS.readdir(path),
120
122
  stat: async (path) => {
@@ -85,6 +85,9 @@ export interface BlenderFileStat {
85
85
  export interface BlenderFiles {
86
86
  readFile(path: string): Promise<Uint8Array>;
87
87
  writeFile(path: string, data: string | Uint8Array): Promise<void>;
88
+ /** Stream a known-size file into the engine without a second whole-file JS buffer.
89
+ * A failed transfer leaves the previous file intact. */
90
+ writeFileStream?(path: string, body: ReadableStream<Uint8Array>, size: number): Promise<void>;
88
91
  mkdirTree(path: string): Promise<void>;
89
92
  readdir(path: string): Promise<string[]>;
90
93
  /** Null when the path is not there. */
@@ -0,0 +1,19 @@
1
+ /** Buffers at every depth of a frame move to the presenter exactly once. */
2
+ export function frameTransferBuffers(frame: unknown): ArrayBuffer[] {
3
+ const buffers = new Set<ArrayBuffer>();
4
+ const visited = new Set<object>();
5
+ const walk = (value: unknown): void => {
6
+ if (value === null || typeof value !== 'object' || visited.has(value)) return;
7
+ visited.add(value);
8
+ if (value instanceof ArrayBuffer) {
9
+ buffers.add(value);
10
+ } else if (ArrayBuffer.isView(value)) {
11
+ // Shared Wasm memory cannot be transferred; arena readers make owned copies.
12
+ if (value.buffer instanceof ArrayBuffer) buffers.add(value.buffer);
13
+ } else {
14
+ for (const child of Object.values(value)) walk(child);
15
+ }
16
+ };
17
+ walk(frame);
18
+ return [...buffers];
19
+ }
@@ -0,0 +1,54 @@
1
+ /** File imports publish only after every declared byte arrived. WasmFS stores the one final
2
+ * backing allocation; each write and its temporary Wasm copy are bounded to one MiB. */
3
+ export interface StreamFileSystem {
4
+ open(path: string, flags: string): { fd: number };
5
+ close(stream: { fd: number }): void;
6
+ write(stream: { fd: number }, data: Uint8Array, offset: number, length: number, position: number): number;
7
+ truncate(path: string, length: number): void;
8
+ rename(from: string, to: string): void;
9
+ unlink(path: string): void;
10
+ }
11
+
12
+ export async function writeStreamedFile(
13
+ FS: StreamFileSystem,
14
+ path: string,
15
+ body: ReadableStream<Uint8Array>,
16
+ size: number,
17
+ ): Promise<void> {
18
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error(`${path}: invalid file size ${size}`);
19
+ const temporary = `${path}.volter-stage-${crypto.randomUUID()}`;
20
+ const reader = body.getReader();
21
+ let stream: { fd: number } | undefined;
22
+ let complete = false;
23
+ try {
24
+ stream = FS.open(temporary, 'w');
25
+ // Reserve the exact backing once: growing WasmFS's vector while importing can otherwise
26
+ // hold both the old and the new allocation in the non-shrinking Wasm heap.
27
+ FS.truncate(temporary, size);
28
+ let position = 0;
29
+ for (;;) {
30
+ const { done, value } = await reader.read();
31
+ if (done) break;
32
+ if (position + value.byteLength > size) throw new Error(`${path}: file grew during import`);
33
+ for (let offset = 0; offset < value.byteLength;) {
34
+ const length = Math.min(1024 * 1024, value.byteLength - offset);
35
+ const written = FS.write(stream, value, offset, length, position);
36
+ if (written <= 0 || written > length) throw new Error(`${path}: invalid file write (${written})`);
37
+ offset += written;
38
+ position += written;
39
+ }
40
+ }
41
+ if (position !== size) throw new Error(`${path}: expected ${size} bytes, received ${position}`);
42
+ FS.close(stream);
43
+ stream = undefined;
44
+ FS.rename(temporary, path);
45
+ complete = true;
46
+ } finally {
47
+ if (!complete) await reader.cancel().catch(() => {});
48
+ reader.releaseLock();
49
+ if (stream) FS.close(stream);
50
+ if (!complete) {
51
+ try { FS.unlink(temporary); } catch { /* open may have failed before creating the file */ }
52
+ }
53
+ }
54
+ }
package/browser/worker.ts CHANGED
@@ -39,6 +39,7 @@
39
39
 
40
40
  import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
41
41
  import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
42
+ import { frameTransferBuffers } from './frame-transfer.mts';
42
43
  import { documentChunks } from './document-chunks.mts';
43
44
  import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
44
45
 
@@ -75,35 +76,24 @@ function presentToTab(
75
76
  const id = ++presentId;
76
77
  // A drawn mesh's buffers move to the tab rather than being copied: the
77
78
  // frame is the tab's from here on.
78
- const transfer: ArrayBuffer[] = [];
79
- // A picture travels the same way and for the same reason: an entry in the
80
- // frame's `images` carries a raw RGBA raster the tab uploads into a
81
- // `DataTexture` (`blender-runtime-view.ts`), once per image per revision.
82
- const parts = frame as {
83
- meshes?: Record<string, unknown>;
84
- images?: Record<string, unknown>;
85
- } | null;
86
- for (const carrier of [parts?.meshes ?? {}, parts?.images ?? {}])
87
- for (const held of Object.values(carrier))
88
- for (const value of Object.values(held as Record<string, unknown>))
89
- if (
90
- ArrayBuffer.isView(value) &&
91
- value.buffer instanceof ArrayBuffer &&
92
- !transfer.includes(value.buffer)
93
- )
94
- transfer.push(value.buffer);
79
+ const transfer = frameTransferBuffers(frame);
95
80
  return new Promise((resolve, reject) => {
96
81
  pendingPresents.set(id, { resolve, reject });
97
- (self as unknown as Worker).postMessage(
98
- {
99
- op: 'present',
100
- id,
101
- frame,
102
- description,
103
- ...(capture ? { capture } : {}),
104
- } satisfies WorkerReply,
105
- transfer,
106
- );
82
+ try {
83
+ (self as unknown as Worker).postMessage(
84
+ {
85
+ op: 'present',
86
+ id,
87
+ frame,
88
+ description,
89
+ ...(capture ? { capture } : {}),
90
+ } satisfies WorkerReply,
91
+ transfer,
92
+ );
93
+ } catch (error) {
94
+ pendingPresents.delete(id);
95
+ reject(error);
96
+ }
107
97
  });
108
98
  }
109
99
 
@@ -535,7 +525,11 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
535
525
  }
536
526
  const dir = path.slice(0, path.lastIndexOf('/'));
537
527
  if (dir) await files_.mkdirTree(dir);
538
- await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
528
+ if (files_.writeFileStream && answer.body) {
529
+ await files_.writeFileStream(path, answer.body, file.size);
530
+ } else {
531
+ await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
532
+ }
539
533
  staged.set(path, { host: stamp, engine: await stampOf(files_, path) });
540
534
  }
541
535
  }
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.50",
5
+ "version": "0.1.52",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
package/wasm/BUNDLE.json CHANGED
@@ -1,12 +1,10 @@
1
1
  {
2
2
  "blender": "5.2.0 LTS",
3
- "source": "1b2547991d6841a6ea4c1394b8d648086b9af6c9",
4
- "reviewedSource": "1b2547991d6841a6ea4c1394b8d648086b9af6c9",
3
+ "source": "e5d7eec67572dfc7c73f8f4d3f601d03442546f9",
4
+ "reviewedSource": "e5d7eec67572dfc7c73f8f4d3f601d03442546f9",
5
5
  "upstream": "fbe6228777e7d9afefcd61a413844e790ae75db7",
6
6
  "sourceRepository": "https://github.com/volter-ai/blender",
7
- "sourceRecipe": "https://github.com/volter-ai/blender/blob/1b2547991d6841a6ea4c1394b8d648086b9af6c9/build_files/emscripten/RELEASE.md",
8
- "sourceRelease": "https://github.com/volter-ai/blender/releases/tag/blender-5.2.0-wasm.4",
9
- "verification": "https://github.com/volter-ai/blender/releases/download/blender-5.2.0-wasm.4/VERIFICATION.json",
7
+ "sourceRecipe": "https://github.com/volter-ai/blender/blob/e5d7eec67572dfc7c73f8f4d3f601d03442546f9/build_files/emscripten/RELEASE.md",
10
8
  "factoryEngine": "BLENDER_EEVEE",
11
9
  "files": {
12
10
  "blender_browser.js": "raw",
@@ -19,8 +17,8 @@
19
17
  "sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
20
18
  },
21
19
  "blender_browser.wasm": {
22
- "bytes": 86318165,
23
- "sha256": "722b181cec94f1bdf2f088e0ae92f04ffa5a79c267bf5b04af7260c16e34eb0f"
20
+ "bytes": 86317655,
21
+ "sha256": "0ba94ac8388de893bcec4da70516aea69128addaa490ef27f17ca06262ee6546"
24
22
  },
25
23
  "blender_browser.data": {
26
24
  "bytes": 52698568,
@@ -33,8 +31,8 @@
33
31
  "sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
34
32
  },
35
33
  "blender_browser.wasm.br": {
36
- "bytes": 18597884,
37
- "sha256": "55ef6f9d63a4c8afa7e7aec4388d1f8c462f5294fcbba8bfbbabe5c41a13f8f9"
34
+ "bytes": 18596913,
35
+ "sha256": "64af23954e1911ffcb879f69a9e8c9bbd83b5531939b586228f44fcea8211e3b"
38
36
  },
39
37
  "blender_browser.data.br": {
40
38
  "bytes": 8056563,
@@ -45,9 +43,9 @@
45
43
  "sha256": "4085e9336266cf66f061d4d5c962a5929cc96d5732c746b5c13ea05cd104a75b"
46
44
  }
47
45
  },
48
- "note": "Official Blender v5.2.0 plus our headless wasm changes and our existing export/session integration. Dependencies are rebuilt from pinned official sources. No third-party Blender port commits or build scripts are inputs. PROXY_TO_PTHREAD, WasmFS, 512 MiB initial / 4 GiB maximum memory, twelve-worker pool. Build information is disabled. The source release includes the exact source tree, materialized startup.blend, dependency archives, pure Python wheels, and notices.",
46
+ "note": "Unreleased Stoneguard candidate. Clean source build at the named commit; Release -O3 objects and -O2 link, Emscripten 6.0.5. Focused geometry regression matches shipped engine across original, vertex edit and subdivision-level edit. Full historical battery is absent from current editor checkout; no full-battery claim.",
49
47
  "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.",
50
48
  "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.",
51
- "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.",
49
+ "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.",
52
50
  "preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
53
51
  }
Binary file