@volter/blender-engine 0.1.44 → 0.1.45

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
+ }
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);
@@ -217,21 +219,40 @@ async function saveDocument(): Promise<void> {
217
219
  // call re-fetch the whole document over the session's own save, and would
218
220
  // hide it from `list-files` (which lists only what the SESSION owns).
219
221
  staged.delete(answer.path);
222
+ let sent = 0;
220
223
  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}`);
224
+ const chunks = await documentChunks(bytes);
225
+ const query = `path=${encodeURIComponent(relative)}`;
226
+ const manifest = JSON.stringify({ chunks: chunks.map(({ hash, start, end }) => [hash, end - start]) });
227
+ // The manifest asks for what the server lacks; after those are sent it commits. A second
228
+ // refusal can only be a file changed underneath between the two, so a third ask is the bound.
229
+ for (let round = 0; ; round += 1) {
230
+ const posted = await fetch(`/__editor/blender-document?${query}`, {
231
+ method: 'POST',
232
+ headers: { 'content-type': 'application/json' },
233
+ body: manifest,
234
+ });
235
+ const said = (await posted.json().catch(() => null)) as { ok?: boolean; missing?: string[]; error?: string } | null;
236
+ if (!posted.ok) throw new Error(`HTTP ${posted.status} ${said?.error ?? ''}`);
237
+ if (said?.ok) break;
238
+ if (round === 2 || !Array.isArray(said?.missing)) throw new Error(`the server still lacks ${said?.missing?.length ?? '?'} chunks`);
239
+ const wanted = new Set(said.missing);
240
+ for (const chunk of chunks) {
241
+ if (!wanted.delete(chunk.hash)) continue;
242
+ const part = await fetch(`/__editor/blender-document-chunk?${query}&hash=${chunk.hash}`, {
243
+ method: 'POST',
244
+ headers: { 'content-type': 'application/octet-stream' },
245
+ body: new Blob([bytes.subarray(chunk.start, chunk.end) as BlobPart]),
246
+ });
247
+ if (!part.ok) throw new Error(`HTTP ${part.status} ${await part.text().catch(() => '')}`);
248
+ sent += chunk.end - chunk.start;
249
+ }
229
250
  }
230
251
  } catch (error) {
231
252
  throw new Error(`The Blender document ${relative} was not written to the project: ${describeThrown(error)}`);
232
253
  }
233
254
  setDocumentDirty(false);
234
- log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length })}`);
255
+ log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length, sent })}`);
235
256
  }
236
257
 
237
258
  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.45",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },