@volter/blender-engine 0.1.44 → 0.1.46

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,72 @@
1
+ /**
2
+ * A saved `.blend` cut into content-defined chunks, the unit a document save travels in
3
+ * (`editor-blender/serving/blender-routes.ts`, `/__editor/blender-document`).
4
+ *
5
+ * WHY CONTENT-DEFINED. A save rewrites the whole file, but an edit changes little of it:
6
+ * MEASURED 2026-09-29, native Blender 5.2 on the Stoneguard bridge, one object moved between two
7
+ * saves changed 1 of 475 one-MiB chunks of a 497 MB file. Fixed offsets would keep that only while
8
+ * no block changes size; an edit that grows a mesh would shift every chunk after it. Cutting where
9
+ * the bytes themselves say (a gear hash, as FastCDC does) re-aligns right after the change, so only
10
+ * the chunks around it are new.
11
+ *
12
+ * The bounds are the transport's: no chunk above {@link CHUNK_MAX}, which the server refuses.
13
+ */
14
+
15
+ export const CHUNK_MIN = 256 * 1024;
16
+ export const CHUNK_MAX = 4 * 1024 * 1024;
17
+ /** 20 bits: a cut about every MiB past the minimum. */
18
+ const CUT_MASK = (1 << 20) - 1;
19
+
20
+ /** The gear table: 256 fixed pseudo-random words (xorshift32 from a fixed seed), so every page
21
+ * cuts the same bytes the same way. */
22
+ const GEAR = (() => {
23
+ const table = new Uint32Array(256);
24
+ let state = 0x9e3779b9;
25
+ for (let index = 0; index < 256; index += 1) {
26
+ state ^= state << 13;
27
+ state ^= state >>> 17;
28
+ state ^= state << 5;
29
+ table[index] = state >>> 0;
30
+ }
31
+ return table;
32
+ })();
33
+
34
+ /** The end offsets of each chunk of `bytes`. */
35
+ export function chunkBoundaries(bytes: Uint8Array): number[] {
36
+ const ends: number[] = [];
37
+ let start = 0;
38
+ while (start < bytes.length) {
39
+ const limit = Math.min(bytes.length, start + CHUNK_MAX);
40
+ let end = limit;
41
+ let hash = 0;
42
+ for (let at = start + Math.min(CHUNK_MIN, limit - start); at < limit; at += 1) {
43
+ hash = ((hash << 1) + GEAR[bytes[at]!]!) >>> 0;
44
+ if ((hash & CUT_MASK) === 0) {
45
+ end = at + 1;
46
+ break;
47
+ }
48
+ }
49
+ ends.push(end);
50
+ start = end;
51
+ }
52
+ return ends;
53
+ }
54
+
55
+ export interface DocumentChunk {
56
+ readonly hash: string;
57
+ readonly start: number;
58
+ readonly end: number;
59
+ }
60
+
61
+ /** Each chunk of `bytes` with its SHA-256, in file order. */
62
+ export async function documentChunks(bytes: Uint8Array): Promise<DocumentChunk[]> {
63
+ const chunks: DocumentChunk[] = [];
64
+ let start = 0;
65
+ for (const end of chunkBoundaries(bytes)) {
66
+ // `slice`, not `subarray`: WebCrypto refuses a view of shared memory, which an engine read may be.
67
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes.slice(start, end)));
68
+ chunks.push({ hash: Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join(''), start, end });
69
+ start = end;
70
+ }
71
+ return chunks;
72
+ }
@@ -225,6 +225,12 @@ export class BlenderRuntime {
225
225
  return this.#document;
226
226
  }
227
227
 
228
+ /** Blender's last lines of output (the worker's log, newest last): what a status read shows,
229
+ * including each document save's size and phase timings (`@@VOLTER-DOCUMENT`). */
230
+ get lastOutput(): readonly string[] {
231
+ return [...this.#lastLines];
232
+ }
233
+
228
234
  /** Idempotent: the first call boots Blender at `project`'s
229
235
  * absolute path; later calls await it (a different path is refused).
230
236
  *
package/browser/worker.ts CHANGED
@@ -25,7 +25,8 @@
25
25
  * So the session marks a present `saveDue`, this file finishes the command,
26
26
  * calls `save-document` as an ordinary request (which Python's loop
27
27
  * picks up BETWEEN calls, never mid-call), and carries the bytes to the
28
- * project through `/__editor/blender-document` BEFORE acknowledging the edit.
28
+ * project through `/__editor/blender-document` BEFORE acknowledging the edit —
29
+ * as a delta: the chunks the server does not hold (`document-chunks.mts`).
29
30
  *
30
31
  * WHY THE CARRY IS NOT THE MIRROR'S JOB (`volter blender-mcp`, class Mirror):
31
32
  * the Mirror is pull-based and runs only after an `execute_blender_code`, so
@@ -38,6 +39,7 @@
38
39
 
39
40
  import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
40
41
  import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
42
+ import { documentChunks } from './document-chunks.mts';
41
43
  import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
42
44
 
43
45
  const post = (reply: WorkerReply) => (self as unknown as Worker).postMessage(reply);
@@ -196,9 +198,19 @@ function setDocumentDirty(dirty: boolean): void {
196
198
  async function saveDocument(): Promise<void> {
197
199
  if (!engine || documentPath === null) return;
198
200
  const relative = documentPath;
201
+ // Each phase's milliseconds, reported with the save: on a large document the save is the
202
+ // edit's cost, and where it goes differs by host (a tab-served project runs the server here).
203
+ const ms: Record<string, number> = {};
204
+ let mark = performance.now();
205
+ const lap = (phase: string) => {
206
+ const now = performance.now();
207
+ ms[phase] = Math.round(now - mark);
208
+ mark = now;
209
+ };
199
210
  let answer: { saved?: boolean; path?: string; size?: number };
200
211
  try {
201
212
  answer = (await engine.request({ op: 'save-document' })) as typeof answer;
213
+ lap('save');
202
214
  } catch (error) {
203
215
  // A document that cannot be written is the session's work at risk, so it
204
216
  // is a named condition in the editor's console, not a debug line.
@@ -209,6 +221,7 @@ async function saveDocument(): Promise<void> {
209
221
  let bytes: Uint8Array;
210
222
  try {
211
223
  bytes = await engine.files.readFile(answer.path);
224
+ lap('read');
212
225
  } catch (error) {
213
226
  throw new Error(`The Blender document ${relative} could not be read back out of the engine: ${describeThrown(error)}`);
214
227
  }
@@ -217,21 +230,48 @@ async function saveDocument(): Promise<void> {
217
230
  // call re-fetch the whole document over the session's own save, and would
218
231
  // hide it from `list-files` (which lists only what the SESSION owns).
219
232
  staged.delete(answer.path);
233
+ let sent = 0;
220
234
  try {
221
- const posted = await fetch(`/__editor/blender-document?path=${encodeURIComponent(relative)}`, {
222
- method: 'POST',
223
- headers: { 'content-type': 'application/octet-stream' },
224
- body: new Blob([bytes as BlobPart]),
225
- });
226
- if (!posted.ok) {
227
- const said = await posted.text().catch(() => '');
228
- throw new Error(`HTTP ${posted.status} ${said}`);
235
+ const chunks = await documentChunks(bytes);
236
+ lap('chunk');
237
+ const query = `path=${encodeURIComponent(relative)}`;
238
+ const manifest = JSON.stringify({ chunks: chunks.map(({ hash, start, end }) => [hash, end - start]) });
239
+ // The manifest asks for what the server lacks; after those are sent it commits. A second
240
+ // refusal can only be a file changed underneath between the two, so a third ask is the bound.
241
+ for (let round = 0; ; round += 1) {
242
+ const posted = await fetch(`/__editor/blender-document?${query}`, {
243
+ method: 'POST',
244
+ headers: { 'content-type': 'application/json' },
245
+ body: manifest,
246
+ });
247
+ const said = (await posted.json().catch(() => null)) as
248
+ | { ok?: boolean; missing?: string[]; error?: string; ms?: Record<string, number> }
249
+ | null;
250
+ lap(`manifest${round}`);
251
+ if (!posted.ok) throw new Error(`HTTP ${posted.status} ${said?.error ?? ''}`);
252
+ if (said?.ok) {
253
+ for (const [phase, value] of Object.entries(said.ms ?? {})) ms[`server.${phase}`] = value;
254
+ break;
255
+ }
256
+ if (round === 2 || !Array.isArray(said?.missing)) throw new Error(`the server still lacks ${said?.missing?.length ?? '?'} chunks`);
257
+ const wanted = new Set(said.missing);
258
+ for (const chunk of chunks) {
259
+ if (!wanted.delete(chunk.hash)) continue;
260
+ const part = await fetch(`/__editor/blender-document-chunk?${query}&hash=${chunk.hash}`, {
261
+ method: 'POST',
262
+ headers: { 'content-type': 'application/octet-stream' },
263
+ body: new Blob([bytes.subarray(chunk.start, chunk.end) as BlobPart]),
264
+ });
265
+ if (!part.ok) throw new Error(`HTTP ${part.status} ${await part.text().catch(() => '')}`);
266
+ sent += chunk.end - chunk.start;
267
+ }
268
+ lap(`send${round}`);
229
269
  }
230
270
  } catch (error) {
231
271
  throw new Error(`The Blender document ${relative} was not written to the project: ${describeThrown(error)}`);
232
272
  }
233
273
  setDocumentDirty(false);
234
- log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length })}`);
274
+ log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length, sent, ms })}`);
235
275
  }
236
276
 
237
277
  async function startBlender(project: string, document?: 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.44",
5
+ "version": "0.1.46",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },