@volter/blender-engine 0.1.53 → 0.1.55

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.
@@ -0,0 +1,71 @@
1
+ /** Bounded, acknowledged frame transport. One value (one mesh/image or the
2
+ * metadata frame) is assembled at a time; SHA-256 covers every chunk, including
3
+ * the metadata. Nothing is applied before all of that value has been verified. */
4
+ export const FRAME_CHUNK_BYTES = 1024 * 1024;
5
+ export type FrameChunk =
6
+ | { kind: 'begin'; lengths: number[] }
7
+ | { kind: 'bytes'; slot: number; offset: number; bytes: Uint8Array; sha256: string }
8
+ | { kind: 'end' };
9
+ const arrays = { Uint8Array, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array };
10
+ const digest = async (bytes: Uint8Array): Promise<string> =>
11
+ Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>)), b => b.toString(16).padStart(2, '0')).join('');
12
+
13
+ export async function sendFrameValue(value: unknown, send: (chunk: FrameChunk) => Promise<unknown>): Promise<unknown> {
14
+ const buffers: Uint8Array[] = [];
15
+ const json = JSON.stringify(value, (_key, item: unknown) => {
16
+ if (!ArrayBuffer.isView(item)) return item;
17
+ const type = item.constructor.name;
18
+ if (!Object.hasOwn(arrays, type)) throw new Error(`Unsupported frame column ${type}`);
19
+ buffers.push(new Uint8Array(item.buffer, item.byteOffset, item.byteLength));
20
+ return { $frameColumn: buffers.length, type };
21
+ });
22
+ buffers.unshift(new TextEncoder().encode(json));
23
+ await send({ kind: 'begin', lengths: buffers.map(b => b.byteLength) });
24
+ for (const [slot, buffer] of buffers.entries()) {
25
+ for (let offset = 0; offset < buffer.byteLength; offset += FRAME_CHUNK_BYTES) {
26
+ const bytes = buffer.slice(offset, offset + FRAME_CHUNK_BYTES);
27
+ const sha256 = await digest(bytes);
28
+ await send({ kind: 'bytes', slot, offset, bytes, sha256 });
29
+ }
30
+ }
31
+ return send({ kind: 'end' });
32
+ }
33
+
34
+ export class FrameStreamReader {
35
+ private buffers: Uint8Array[] | null = null;
36
+ private offsets: number[] = [];
37
+ reset(): void { this.buffers = null; this.offsets = []; }
38
+ async accept(chunk: FrameChunk): Promise<{ value: unknown } | undefined> {
39
+ try {
40
+ if (chunk.kind === 'begin') {
41
+ if (this.buffers) throw new Error('A frame value is already arriving');
42
+ if (!chunk.lengths.length || chunk.lengths.length > 4096 || chunk.lengths.reduce((sum, n) => sum + n, 0) > 2 * 1024 ** 3 || chunk.lengths.some(n => !Number.isSafeInteger(n) || n < 0 || n > 1024 ** 3))
43
+ throw new Error('Invalid frame buffer lengths');
44
+ this.buffers = chunk.lengths.map(n => new Uint8Array(n));
45
+ this.offsets = chunk.lengths.map(() => 0);
46
+ return;
47
+ }
48
+ if (!this.buffers) throw new Error('Frame chunk arrived without its manifest');
49
+ if (chunk.kind === 'bytes') {
50
+ const buffer = this.buffers[chunk.slot];
51
+ if (!buffer || !Number.isSafeInteger(chunk.offset) || chunk.offset !== this.offsets[chunk.slot] || chunk.bytes.byteLength > FRAME_CHUNK_BYTES || !chunk.bytes.byteLength || chunk.offset + chunk.bytes.byteLength > buffer.byteLength)
52
+ throw new Error('Frame chunk is out of order or exceeds its bound');
53
+ if (await digest(chunk.bytes) !== chunk.sha256) throw new Error('Frame chunk digest mismatch');
54
+ buffer.set(chunk.bytes, chunk.offset); this.offsets[chunk.slot] = chunk.offset + chunk.bytes.byteLength;
55
+ return;
56
+ }
57
+ if (chunk.kind !== 'end') throw new Error('Unknown frame chunk');
58
+ const buffers = this.buffers;
59
+ if (buffers.some((b, i) => b.length !== this.offsets[i])) throw new Error('Frame value is incomplete');
60
+ const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffers[0]), (_key, item) => {
61
+ if (!item || typeof item !== 'object' || !('$frameColumn' in item)) return item;
62
+ const bytes = buffers[item.$frameColumn];
63
+ const Type = arrays[item.type as keyof typeof arrays];
64
+ if (!Number.isSafeInteger(item.$frameColumn) || item.$frameColumn < 1 || !bytes || !Object.hasOwn(arrays, item.type) || !Type || bytes.byteLength % Type.BYTES_PER_ELEMENT)
65
+ throw new Error('Invalid frame column descriptor');
66
+ return new Type(bytes.buffer as ArrayBuffer);
67
+ });
68
+ this.reset(); return { value };
69
+ } catch (error) { this.reset(); throw error; }
70
+ }
71
+ }
@@ -1,3 +1,4 @@
1
+ import type { FrameChunk } from './frame-stream.mts';
1
2
  /** Messages between the editor tab and its Blender worker. One session per
2
3
  * worker; the model lives in the worker's Python and nowhere else. */
3
4
 
@@ -97,6 +98,7 @@ export type WorkerRequest =
97
98
  };
98
99
 
99
100
  export type WorkerReply =
101
+ | { op: 'frame-stream'; id: number; chunk: FrameChunk }
100
102
  | { op: 'document-dirty'; dirty: boolean }
101
103
  | { op: 'history'; entries: NativeHistoryEntry[] }
102
104
  | { id: number; result: unknown }
@@ -1,3 +1,4 @@
1
+ import { FrameStreamReader } from './frame-stream.mts';
1
2
  /**
2
3
  * The tab-side handle on a Blender session: spawns the worker, forwards the
3
4
  * four tool calls, and hands every presented frame to whoever displays it.
@@ -27,6 +28,8 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
27
28
  type Request = DistributiveOmit<WorkerRequest, 'id'>;
28
29
 
29
30
  export interface BlenderRuntimeOptions {
31
+ /** Stage one verified mesh/image without replacing the displayed frame. */
32
+ stage?(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
30
33
  /** Operation-only diagnostics, never request payloads. The optional observer
31
34
  * must not affect work, even if it throws. End calls balance overlapping work. */
32
35
  work?(label: string): () => void;
@@ -478,6 +481,7 @@ export class BlenderRuntime {
478
481
 
479
482
  /** Forced teardown for a lost session; explicit user stops must use stop(). */
480
483
  terminate(): void {
484
+ this.#discardStagedFrame();
481
485
  if (this.#terminated) return;
482
486
  this.#terminated = true;
483
487
  globalThis.removeEventListener?.('beforeunload', this.#beforeUnload);
@@ -570,8 +574,39 @@ export class BlenderRuntime {
570
574
  } catch { return () => {}; }
571
575
  }
572
576
 
577
+ readonly #frameStream = new FrameStreamReader();
578
+ #stagedIdentity: { session: string; revision: number } | null = null;
579
+ #discardStagedFrame(): void {
580
+ this.#frameStream.reset();
581
+ const part = this.#stagedIdentity; this.#stagedIdentity = null;
582
+ if (part) {
583
+ try { void Promise.resolve(this.#options.stage?.({ ...part, abort: true })).catch(() => {}); }
584
+ catch { /* Cleanup cannot replace the original operation failure. */ }
585
+ }
586
+ }
587
+
573
588
  async #receive(reply: WorkerReply): Promise<void> {
574
589
  if ('op' in reply) {
590
+ if (reply.op === 'frame-stream') {
591
+ try {
592
+ const decoded = await this.#frameStream.accept(reply.chunk);
593
+ if (decoded) {
594
+ const value = decoded.value as ({ op: 'stage'; session: string; revision: number } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
595
+ if (value.op === 'present') {
596
+ await this.#receive({ ...value, id: reply.id } as WorkerReply);
597
+ return;
598
+ }
599
+ if (value.op !== 'stage' || !this.#options.stage) throw new Error('The presenter does not support staged Blender frames');
600
+ this.#stagedIdentity = { session: value.session, revision: value.revision };
601
+ await this.#options.stage(value);
602
+ }
603
+ this.#worker.postMessage({ op: 'present-result', id: reply.id } satisfies WorkerRequest);
604
+ } catch (error) {
605
+ this.#discardStagedFrame();
606
+ this.#worker.postMessage({ op: 'present-result', id: reply.id, error: error instanceof Error ? error.message : String(error) } satisfies WorkerRequest);
607
+ }
608
+ return;
609
+ }
575
610
  if (reply.op === 'document-dirty') {
576
611
  this.#dirty = reply.dirty;
577
612
  return;
@@ -600,6 +635,7 @@ export class BlenderRuntime {
600
635
  const end = this.#work('presenting worker frame');
601
636
  try {
602
637
  const answer = await this.#options.present(reply.frame, reply.description, reply.capture);
638
+ this.#stagedIdentity = null;
603
639
  this.#worker.postMessage({
604
640
  op: 'present-result',
605
641
  id: reply.id,
@@ -609,6 +645,7 @@ export class BlenderRuntime {
609
645
  : {}),
610
646
  } satisfies WorkerRequest);
611
647
  } catch (error) {
648
+ this.#discardStagedFrame();
612
649
  this.#worker.postMessage({
613
650
  op: 'present-result',
614
651
  id: reply.id,
@@ -619,6 +656,7 @@ export class BlenderRuntime {
619
656
  }
620
657
  return;
621
658
  }
659
+ if ('error' in reply) this.#discardStagedFrame();
622
660
  const pending = this.#pending.get(reply.id);
623
661
  if (!pending) return;
624
662
  this.#pending.delete(reply.id);
@@ -91,15 +91,14 @@ function readColumn(arena: Uint8Array, name: string, descriptor: ColumnDescripto
91
91
  * Every `{offset, length, dtype, count, stride}` in the frame, replaced by its
92
92
  * typed array.
93
93
  *
94
- * `co` is widened to `Float64Array` because that is what `MeshColumns`
95
- * declares and `drawArraysFromColumns` is typed against, while Blender's own
96
- * vertex array is single precision.
94
+ * Positions keep Blender's own precision. Widening a float column would
95
+ * double its transfer/storage without adding information.
97
96
  */
98
97
  export function columnsToTypedArrays(arena: Uint8Array, frame: unknown): unknown {
99
98
  const walk = (value: unknown, key: string): unknown => {
100
99
  if (isColumnDescriptor(value)) {
101
100
  const view = readColumn(arena, key, value);
102
- return key === 'co' ? Float64Array.from(view) : view;
101
+ return view;
103
102
  }
104
103
  if (Array.isArray(value)) return value.map((entry) => walk(entry, key));
105
104
  if (typeof value === 'object' && value !== null) {
@@ -78,7 +78,7 @@ export const ATTRIBUTE_LAYOUT: Record<AttributeType, { size: number }> = {
78
78
  /** Exactly what the draw reads off a presented mesh. */
79
79
  export interface MeshColumns {
80
80
  /** 3 * nv */
81
- co: Float64Array;
81
+ co: Float32Array | Float64Array;
82
82
  /** Blender-evaluated normals, 3 per corner, independent of UV splits. */
83
83
  cornerNormal?: Float32Array | undefined;
84
84
  /** 3 * nv, a deformed mesh's Generated coordinates in Blender's stored
@@ -184,7 +184,7 @@ const unchangedMeshSchema = z
184
184
  */
185
185
  const columnsSchema = z
186
186
  .object({
187
- co: z.instanceof(Float64Array),
187
+ co: z.union([z.instanceof(Float32Array), z.instanceof(Float64Array)]),
188
188
  cornerNormal: z.instanceof(Float32Array).optional(),
189
189
  orco: z.instanceof(Float32Array).optional(),
190
190
  faceStart: z.instanceof(Uint32Array),
@@ -1442,6 +1442,33 @@ export class BlenderRuntimeView {
1442
1442
  group.visible = !this.capturing;
1443
1443
  }
1444
1444
 
1445
+ private staged: { session: string; revision: number; meshes: Map<string, { signature: string; geometry: THREE.BufferGeometry }>; images: Map<string, z.infer<typeof frameImageSchema>> } | null = null;
1446
+
1447
+ /** Prepare one mesh as soon as its bounded transfer completes. Source
1448
+ * columns are released before the next mesh; displayed resources change
1449
+ * only when the final frame manifest is validated. */
1450
+ stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void {
1451
+ if (part.abort || (!part.mesh && !part.image)) {
1452
+ for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
1453
+ this.staged = part.abort ? null : { session: part.session, revision: part.revision, meshes: new Map(), images: new Map() };
1454
+ return;
1455
+ }
1456
+ const staged = this.staged;
1457
+ if (!staged || staged.session !== part.session || staged.revision !== part.revision)
1458
+ throw new Error('Blender frame piece does not belong to the pending revision');
1459
+ if (part.mesh !== undefined) {
1460
+ const data = exportedMeshSchema.parse(part.piece);
1461
+ if (staged.meshes.has(part.mesh)) throw new Error('Duplicate Blender mesh piece');
1462
+ const signature = `revision:${data.revision}`;
1463
+ staged.meshes.set(part.mesh, { signature, geometry: geometryFromDrawArrays(drawArraysFromColumns({
1464
+ ...data.columns, attributes: data.attributes as never, activeUv: data.activeUv, renderUv: data.renderUv,
1465
+ }, signature)) });
1466
+ } else if (part.image !== undefined) {
1467
+ if (staged.images.has(part.image)) throw new Error('Duplicate Blender image piece');
1468
+ staged.images.set(part.image, frameImageSchema.parse(part.piece));
1469
+ }
1470
+ }
1471
+
1445
1472
  applyFrame(input: unknown) {
1446
1473
  // WHAT THIS PRESENTER HELD BEFORE THIS FRAME, read before anything is
1447
1474
  // applied, and carried back to the session in the present's answer.
@@ -1459,6 +1486,10 @@ export class BlenderRuntimeView {
1459
1486
  const held =
1460
1487
  this.frame === null ? null : { session: this.frame.session, revision: this.frame.revision };
1461
1488
  const next = frameSchema.parse(input);
1489
+ const staged = this.staged;
1490
+ if (staged && (staged.session !== next.session || staged.revision !== next.revision))
1491
+ throw new Error('Blender frame manifest does not match its staged revision');
1492
+ if (staged) for (const [name, data] of staged.images) next.images[name] = data;
1462
1493
  if (this.retiredSessions.has(next.session))
1463
1494
  throw new Error(
1464
1495
  'The runtime session was replaced; its delayed frame cannot overwrite the active model',
@@ -1503,6 +1534,12 @@ export class BlenderRuntimeView {
1503
1534
  }
1504
1535
  for (const [id, data] of Object.entries(next.meshes)) {
1505
1536
  if ('unchanged' in data) {
1537
+ const incoming = staged?.meshes.get(id);
1538
+ if (incoming) {
1539
+ if (incoming.signature !== `revision:${data.revision}`) throw new Error('Staged Blender mesh revision mismatch');
1540
+ prepared.set(id, incoming);
1541
+ continue;
1542
+ }
1506
1543
  // A REFERENCE, not geometry: the worker says this store has not been
1507
1544
  // written since the frame it last sent, so the resident
1508
1545
  // BufferGeometry stands. If it is not resident the worker's record
@@ -1567,6 +1604,10 @@ export class BlenderRuntimeView {
1567
1604
  }
1568
1605
  throw error;
1569
1606
  }
1607
+ if (staged) {
1608
+ for (const [id, value] of staged.meshes) if (!prepared.has(id)) value.geometry.dispose();
1609
+ this.staged = null;
1610
+ }
1570
1611
  if (this.frame?.session !== next.session) {
1571
1612
  if (this.frame) this.retiredSessions.add(this.frame.session);
1572
1613
  this.clear();
@@ -1929,7 +1970,7 @@ export class BlenderRuntimeView {
1929
1970
  // nested number arrays it arrived as cost ~15x their JSON (the blower's
1930
1971
  // last frame held 850 MB of main-thread heap this way, measured
1931
1972
  // 2026-09-13). Reuse checks compare the per-mesh signatures kept above.
1932
- this.frame = { ...next, meshes: {}, volumes: {} };
1973
+ this.frame = { ...next, meshes: {}, volumes: {}, images: {} };
1933
1974
  this.worldKey = JSON.stringify(next.world ?? null);
1934
1975
  // THE OVERLAYS, after the graph stands: the weight drawing is laid over
1935
1976
  // the presented mesh's own geometry and the bones over the armature
@@ -2182,6 +2223,8 @@ export class BlenderRuntimeView {
2182
2223
  }
2183
2224
 
2184
2225
  private clear() {
2226
+ for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
2227
+ this.staged = null;
2185
2228
  for (const light of this.lights.values()) {
2186
2229
  light.removeFromParent();
2187
2230
  light.dispose();
package/browser/worker.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { sendFrameValue } from './frame-stream.mts';
1
2
  /**
2
3
  * THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
3
4
  * 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
@@ -39,7 +40,6 @@
39
40
 
40
41
  import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
41
42
  import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
42
- import { frameTransferBuffers } from './frame-transfer.mts';
43
43
  import { documentChunks } from './document-chunks.mts';
44
44
  import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
45
45
 
@@ -68,33 +68,20 @@ const pendingPresents = new Map<
68
68
  { resolve: (value: PresentAnswer) => void; reject: (error: Error) => void }
69
69
  >();
70
70
 
71
- function presentToTab(
72
- frame: unknown,
73
- description: unknown,
74
- capture?: CaptureRequest,
75
- ): Promise<PresentAnswer> {
76
- const id = ++presentId;
77
- // A drawn mesh's buffers move to the tab rather than being copied: the
78
- // frame is the tab's from here on.
79
- const transfer = frameTransferBuffers(frame);
80
- return new Promise((resolve, reject) => {
81
- pendingPresents.set(id, { resolve, reject });
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
- }
97
- });
71
+ async function streamToTab(value: unknown): Promise<PresentAnswer> {
72
+ return await sendFrameValue(value, chunk => {
73
+ const id = ++presentId;
74
+ return new Promise<PresentAnswer>((resolve, reject) => {
75
+ pendingPresents.set(id, { resolve, reject });
76
+ try {
77
+ (self as unknown as Worker).postMessage({ op: 'frame-stream', id, chunk } satisfies WorkerReply,
78
+ chunk.kind === 'bytes' ? [chunk.bytes.buffer as ArrayBuffer] : []);
79
+ } catch (error) { pendingPresents.delete(id); reject(error); }
80
+ });
81
+ }) as PresentAnswer;
82
+ }
83
+ function presentToTab(frame: unknown, description: unknown, capture?: CaptureRequest): Promise<PresentAnswer> {
84
+ return streamToTab({ op: 'present', frame, description, ...(capture ? { capture } : {}) });
98
85
  }
99
86
 
100
87
  let engine: BlenderEngine | null = null;
@@ -122,6 +109,8 @@ interface Copied {
122
109
  description: unknown;
123
110
  }
124
111
  interface PendingFrame {
112
+ session: string;
113
+ revision: number;
125
114
  columns: Map<number, Copied>;
126
115
  meshes: Map<string, Copied>;
127
116
  images: Map<string, Copied>;
@@ -166,7 +155,10 @@ function assemblePending(held: PendingFrame, frame: unknown): { typed: unknown;
166
155
  return piece[side];
167
156
  }
168
157
  const out: Record<string, unknown> = {};
169
- for (const [name, entry] of Object.entries(value)) out[name] = walk(entry, [...path, name]);
158
+ for (const [name, entry] of Object.entries(value)) {
159
+ const item = walk(entry, [...path, name]);
160
+ if (item !== undefined) out[name] = item;
161
+ }
170
162
  return out;
171
163
  };
172
164
  return walk(frame, []);
@@ -285,7 +277,9 @@ async function startBlender(project: string, document?: string): Promise<unknown
285
277
  // copied out of the heap as they come, because the next export call frees them; the tab
286
278
  // gets the whole frame at `present`.
287
279
  if (hold !== undefined) {
288
- pending = { columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
280
+ const identity = hold as { session: string; revision: number };
281
+ pending = { session: identity.session, revision: identity.revision, columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
282
+ await streamToTab({ op: 'stage', session: identity.session, revision: identity.revision });
289
283
  return {};
290
284
  }
291
285
  if (mesh !== undefined || image !== undefined) {
@@ -310,8 +304,14 @@ async function startBlender(project: string, document?: string): Promise<unknown
310
304
  description: await describeFrame(arena, piece),
311
305
  };
312
306
  }
313
- if (mesh !== undefined) pending.meshes.set(mesh, copied);
314
- else pending.images.set(image as string, copied);
307
+ await streamToTab({ op: 'stage', session: pending.session, revision: pending.revision,
308
+ ...(mesh !== undefined ? { mesh } : { image }), piece: copied.typed });
309
+ // Keep only the manifest and digest; the presenter already built this
310
+ // resource, and the next pull may overwrite the engine arena.
311
+ if (mesh !== undefined) {
312
+ const data = copied.typed as { revision: number };
313
+ pending.meshes.set(mesh, { typed: { revision: data.revision, unchanged: true }, description: copied.description });
314
+ } else pending.images.set(image as string, { typed: undefined, description: copied.description });
315
315
  return {};
316
316
  }
317
317
  let typed: 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.53",
5
+ "version": "0.1.55",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },