@volter/editor-blender 0.1.45 → 0.1.47

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.
@@ -1,4 +1,5 @@
1
1
  // serving/blender-routes.ts
2
+ import { createHash as createHash2 } from "node:crypto";
2
3
  import { createReadStream as createReadStream2 } from "node:fs";
3
4
  import { mkdir as mkdir2, readFile as readFile2, rm, stat as stat2, unlink, writeFile as writeFile2 } from "node:fs/promises";
4
5
  import { tmpdir as tmpdir2 } from "node:os";
@@ -316,6 +317,10 @@ function json(res, body, status = 200) {
316
317
  res.setHeader("content-type", "application/json");
317
318
  res.end(JSON.stringify(body));
318
319
  }
320
+ var DOCUMENT_CHUNK_MAX = 4 * 1024 * 1024;
321
+ function sha256(bytes) {
322
+ return createHash2("sha256").update(bytes).digest("hex");
323
+ }
319
324
  async function readBody(req) {
320
325
  const chunks = [];
321
326
  for await (const value of req) chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value));
@@ -335,6 +340,8 @@ function blenderRoutesPlugin(services) {
335
340
  if (!isContainedRelativePath(path)) return false;
336
341
  return path.split("/").every((segment) => segment !== "" && !segment.startsWith("."));
337
342
  };
343
+ const heldChunks = /* @__PURE__ */ new Map();
344
+ const committedChunks = /* @__PURE__ */ new Map();
338
345
  const routes = [
339
346
  // ---- The engine itself: headless Blender, compiled to WebAssembly. Its pthreads load
340
347
  // `blender_browser.js` from this same URL as a classic worker, so the patched glue has to
@@ -631,6 +638,40 @@ function blenderRoutesPlugin(services) {
631
638
  // those bytes reach the disk the project lives on. Unlike the spool, the destination shape is
632
639
  // pinned BY THIS SERVER, and the write is the project's one attributed, conflict-checked
633
640
  // transaction: a `.blend` is the project's file like any other.
641
+ //
642
+ // IT ARRIVES AS A DELTA. The page cuts each save into content-defined chunks
643
+ // (`blender-engine/browser/document-chunks.mts`) and posts their manifest; this answers with
644
+ // the chunks it does not already hold, the page posts those, and the manifest again commits.
645
+ // MEASURED 2026-09-29, native Blender 5.2 on the Stoneguard bridge: a 497 MB save, one object
646
+ // moved, changed 1 of 475 one-MiB chunks, so an edit carries about a megabyte instead of the
647
+ // whole file. Chunks this server reuses come from the document as it lies on disk, and each
648
+ // is hashed again against the manifest before the commit: a file changed underneath (another
649
+ // writer, a restore) is not drift but a missing chunk, re-sent.
650
+ {
651
+ method: "POST",
652
+ match: /^\/__editor\/blender-document-chunk$/,
653
+ handle: async (req, res, url) => {
654
+ const path = url.searchParams.get("path") ?? "";
655
+ const hash = url.searchParams.get("hash") ?? "";
656
+ if (!isDocumentPath(path) || !/^[0-9a-f]{64}$/u.test(hash)) {
657
+ json(res, { error: "A document chunk names its .blend `path` and its SHA-256 `hash` (64 hex digits)." }, 400);
658
+ return;
659
+ }
660
+ const body = await readBody(req);
661
+ if (body.byteLength === 0 || body.byteLength > DOCUMENT_CHUNK_MAX) {
662
+ json(res, { error: `A document chunk is 1 to ${DOCUMENT_CHUNK_MAX} bytes; got ${body.byteLength}.` }, 400);
663
+ return;
664
+ }
665
+ if (sha256(body) !== hash) {
666
+ json(res, { error: `The chunk's bytes do not hash to ${hash}; it was not kept.` }, 400);
667
+ return;
668
+ }
669
+ let held = heldChunks.get(path);
670
+ if (!held) heldChunks.set(path, held = /* @__PURE__ */ new Map());
671
+ held.set(hash, body);
672
+ json(res, { ok: true, bytes: body.byteLength });
673
+ }
674
+ },
634
675
  {
635
676
  method: "POST",
636
677
  match: /^\/__editor\/blender-document$/,
@@ -646,18 +687,77 @@ function blenderRoutesPlugin(services) {
646
687
  );
647
688
  return;
648
689
  }
649
- if (projectRoot() === null) {
690
+ const root = projectRoot();
691
+ if (root === null) {
650
692
  json(res, { error: "No project open, so there is nowhere to save the document." }, 404);
651
693
  return;
652
694
  }
653
- const body = await readBody(req);
654
- if (body.byteLength === 0) {
655
- json(res, { error: "The Blender document body was empty; nothing was written." }, 400);
695
+ const parsed = req.body;
696
+ let manifest = parsed !== null && typeof parsed === "object" && !Buffer.isBuffer(parsed) ? parsed : null;
697
+ if (manifest === null) {
698
+ try {
699
+ manifest = JSON.parse((await readBody(req)).toString("utf8"));
700
+ } catch {
701
+ manifest = null;
702
+ }
703
+ }
704
+ const chunks = manifest?.chunks;
705
+ if (!Array.isArray(chunks) || chunks.length === 0 || !chunks.every(
706
+ (chunk) => Array.isArray(chunk) && typeof chunk[0] === "string" && /^[0-9a-f]{64}$/u.test(chunk[0]) && Number.isInteger(chunk[1]) && chunk[1] > 0 && chunk[1] <= DOCUMENT_CHUNK_MAX
707
+ )) {
708
+ json(res, { error: "A document manifest is `{ chunks: [[sha256, length], ...] }`, at least one chunk." }, 400);
709
+ return;
710
+ }
711
+ const listed = chunks;
712
+ const held = heldChunks.get(path) ?? /* @__PURE__ */ new Map();
713
+ const known = committedChunks.get(path);
714
+ let onDisk = null;
715
+ if (known && listed.some(([hash]) => !held.has(hash) && known.byHash.has(hash))) {
716
+ try {
717
+ const file = join2(root, path);
718
+ const now = await stat2(file);
719
+ if (now.size === known.size && now.mtimeMs === known.mtimeMs) onDisk = await readFile2(file);
720
+ } catch {
721
+ onDisk = null;
722
+ }
723
+ }
724
+ const parts = [];
725
+ const missing = /* @__PURE__ */ new Set();
726
+ for (const [hash, length] of listed) {
727
+ const fresh = held.get(hash);
728
+ if (fresh && fresh.byteLength === length) {
729
+ parts.push(fresh);
730
+ continue;
731
+ }
732
+ const at = onDisk ? known?.byHash.get(hash) : void 0;
733
+ const reused = at !== void 0 && at.length === length ? onDisk.subarray(at.offset, at.offset + length) : null;
734
+ if (reused && sha256(reused) === hash) parts.push(reused);
735
+ else missing.add(hash);
736
+ }
737
+ if (missing.size > 0) {
738
+ json(res, { ok: false, missing: [...missing] });
656
739
  return;
657
740
  }
741
+ const body = Buffer.concat(parts);
658
742
  try {
659
743
  const revision = await services.commitProjectMutation(req, [{ path, content: body }]);
660
- json(res, { ok: true, bytes: body.byteLength, revision: revision?.revision ?? null });
744
+ heldChunks.delete(path);
745
+ const byHash = /* @__PURE__ */ new Map();
746
+ let offset = 0;
747
+ for (const [hash, length] of listed) {
748
+ byHash.set(hash, { offset, length });
749
+ offset += length;
750
+ }
751
+ const written = await stat2(join2(root, path)).catch(() => null);
752
+ if (written && written.size === body.byteLength)
753
+ committedChunks.set(path, { size: written.size, mtimeMs: written.mtimeMs, byHash });
754
+ else committedChunks.delete(path);
755
+ json(res, {
756
+ ok: true,
757
+ bytes: body.byteLength,
758
+ sent: listed.reduce((sum, [hash, length]) => sum + (held.has(hash) ? length : 0), 0),
759
+ revision: revision?.revision ?? null
760
+ });
661
761
  } catch (error) {
662
762
  services.answerProjectMutationError(res, error);
663
763
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/editor-blender",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "AGPL-3.0-only AND GPL-3.0-or-later",
5
- "version": "0.1.45",
5
+ "version": "0.1.47",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -70,14 +70,14 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "1.30.0",
73
- "@volter/blender-engine": "0.1.43",
74
- "@volter/editor-threejs": "0.5.105",
73
+ "@volter/blender-engine": "0.1.45",
74
+ "@volter/editor-threejs": "0.5.107",
75
75
  "zod": "^4.3.6"
76
76
  },
77
77
  "peerDependencies": {
78
- "@volter/editor-live": "0.5.105",
79
- "@volter/editor-project": "0.5.105",
80
- "@volter/editor-sdk": "0.5.105",
78
+ "@volter/editor-live": "0.5.107",
79
+ "@volter/editor-project": "0.5.107",
80
+ "@volter/editor-sdk": "0.5.107",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0",
83
83
  "three": "^0.180.0",
@@ -9,6 +9,7 @@
9
9
  * like every other project write.
10
10
  */
11
11
 
12
+ import { createHash } from 'node:crypto';
12
13
  import { createReadStream } from 'node:fs';
13
14
  import { mkdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
14
15
  import type { IncomingMessage, ServerResponse } from 'node:http';
@@ -42,6 +43,13 @@ function json(res: ServerResponse, body: unknown, status = 200): void {
42
43
  res.end(JSON.stringify(body));
43
44
  }
44
45
 
46
+ /** The largest chunk a document save sends (`document-chunks.mts`'s cut bound). */
47
+ const DOCUMENT_CHUNK_MAX = 4 * 1024 * 1024;
48
+
49
+ function sha256(bytes: Uint8Array): string {
50
+ return createHash('sha256').update(bytes).digest('hex');
51
+ }
52
+
45
53
  async function readBody(req: IncomingMessage): Promise<Buffer> {
46
54
  const chunks: Buffer[] = [];
47
55
  for await (const value of req) chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value as Uint8Array));
@@ -77,6 +85,14 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
77
85
  return path.split('/').every((segment) => segment !== '' && !segment.startsWith('.'));
78
86
  };
79
87
 
88
+ // The document's chunks: those the page sent for the save in flight, and where each chunk of
89
+ // the last committed save lies in the file, valid while the file's size and mtime still match.
90
+ const heldChunks = new Map<string, Map<string, Buffer>>();
91
+ const committedChunks = new Map<
92
+ string,
93
+ { size: number; mtimeMs: number; byHash: Map<string, { offset: number; length: number }> }
94
+ >();
95
+
80
96
  const routes: { method: 'GET' | 'POST'; match: RegExp; handle: Handler }[] = [
81
97
  // ---- The engine itself: headless Blender, compiled to WebAssembly. Its pthreads load
82
98
  // `blender_browser.js` from this same URL as a classic worker, so the patched glue has to
@@ -397,6 +413,40 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
397
413
  // those bytes reach the disk the project lives on. Unlike the spool, the destination shape is
398
414
  // pinned BY THIS SERVER, and the write is the project's one attributed, conflict-checked
399
415
  // transaction: a `.blend` is the project's file like any other.
416
+ //
417
+ // IT ARRIVES AS A DELTA. The page cuts each save into content-defined chunks
418
+ // (`blender-engine/browser/document-chunks.mts`) and posts their manifest; this answers with
419
+ // the chunks it does not already hold, the page posts those, and the manifest again commits.
420
+ // MEASURED 2026-09-29, native Blender 5.2 on the Stoneguard bridge: a 497 MB save, one object
421
+ // moved, changed 1 of 475 one-MiB chunks, so an edit carries about a megabyte instead of the
422
+ // whole file. Chunks this server reuses come from the document as it lies on disk, and each
423
+ // is hashed again against the manifest before the commit: a file changed underneath (another
424
+ // writer, a restore) is not drift but a missing chunk, re-sent.
425
+ {
426
+ method: 'POST',
427
+ match: /^\/__editor\/blender-document-chunk$/,
428
+ handle: async (req, res, url) => {
429
+ const path = url.searchParams.get('path') ?? '';
430
+ const hash = url.searchParams.get('hash') ?? '';
431
+ if (!isDocumentPath(path) || !/^[0-9a-f]{64}$/u.test(hash)) {
432
+ json(res, { error: 'A document chunk names its .blend `path` and its SHA-256 `hash` (64 hex digits).' }, 400);
433
+ return;
434
+ }
435
+ const body = await readBody(req);
436
+ if (body.byteLength === 0 || body.byteLength > DOCUMENT_CHUNK_MAX) {
437
+ json(res, { error: `A document chunk is 1 to ${DOCUMENT_CHUNK_MAX} bytes; got ${body.byteLength}.` }, 400);
438
+ return;
439
+ }
440
+ if (sha256(body) !== hash) {
441
+ json(res, { error: `The chunk's bytes do not hash to ${hash}; it was not kept.` }, 400);
442
+ return;
443
+ }
444
+ let held = heldChunks.get(path);
445
+ if (!held) heldChunks.set(path, (held = new Map()));
446
+ held.set(hash, body);
447
+ json(res, { ok: true, bytes: body.byteLength });
448
+ },
449
+ },
400
450
  {
401
451
  method: 'POST',
402
452
  match: /^\/__editor\/blender-document$/,
@@ -414,20 +464,92 @@ export function blenderRoutesPlugin(services: ProjectServingServices): Plugin {
414
464
  );
415
465
  return;
416
466
  }
417
- if (projectRoot() === null) {
467
+ const root = projectRoot();
468
+ if (root === null) {
418
469
  json(res, { error: 'No project open, so there is nowhere to save the document.' }, 404);
419
470
  return;
420
471
  }
421
- const body = await readBody(req);
422
- // A zero-byte .blend is not a document; writing one would replace a good file with an
423
- // empty one at exactly the moment something went wrong.
424
- if (body.byteLength === 0) {
425
- json(res, { error: 'The Blender document body was empty; nothing was written.' }, 400);
472
+ // The editor server's `express.json` has already read a JSON body when it runs first
473
+ // (MEASURED: the stream arrived empty); read the stream only when it has not.
474
+ const parsed = (req as IncomingMessage & { body?: unknown }).body;
475
+ let manifest: unknown = parsed !== null && typeof parsed === 'object' && !Buffer.isBuffer(parsed) ? parsed : null;
476
+ if (manifest === null) {
477
+ try {
478
+ manifest = JSON.parse((await readBody(req)).toString('utf8'));
479
+ } catch {
480
+ manifest = null;
481
+ }
482
+ }
483
+ const chunks = (manifest as { chunks?: unknown } | null)?.chunks;
484
+ if (
485
+ !Array.isArray(chunks) ||
486
+ chunks.length === 0 ||
487
+ !chunks.every(
488
+ (chunk) =>
489
+ Array.isArray(chunk) &&
490
+ typeof chunk[0] === 'string' &&
491
+ /^[0-9a-f]{64}$/u.test(chunk[0]) &&
492
+ Number.isInteger(chunk[1]) &&
493
+ chunk[1] > 0 &&
494
+ chunk[1] <= DOCUMENT_CHUNK_MAX,
495
+ )
496
+ ) {
497
+ // A zero-chunk manifest is a zero-byte .blend, which is not a document; writing one
498
+ // would replace a good file with an empty one at exactly the moment something went wrong.
499
+ json(res, { error: 'A document manifest is `{ chunks: [[sha256, length], ...] }`, at least one chunk.' }, 400);
426
500
  return;
427
501
  }
502
+ const listed = chunks as [string, number][];
503
+ const held = heldChunks.get(path) ?? new Map<string, Buffer>();
504
+ // What the document on disk already holds, by hash, re-verified below.
505
+ const known = committedChunks.get(path);
506
+ let onDisk: Buffer | null = null;
507
+ if (known && listed.some(([hash]) => !held.has(hash) && known.byHash.has(hash))) {
508
+ try {
509
+ const file = join(root, path);
510
+ const now = await stat(file);
511
+ if (now.size === known.size && now.mtimeMs === known.mtimeMs) onDisk = await readFile(file);
512
+ } catch {
513
+ onDisk = null;
514
+ }
515
+ }
516
+ const parts: Buffer[] = [];
517
+ const missing = new Set<string>();
518
+ for (const [hash, length] of listed) {
519
+ const fresh = held.get(hash);
520
+ if (fresh && fresh.byteLength === length) {
521
+ parts.push(fresh);
522
+ continue;
523
+ }
524
+ const at = onDisk ? known?.byHash.get(hash) : undefined;
525
+ const reused = at !== undefined && at.length === length ? onDisk!.subarray(at.offset, at.offset + length) : null;
526
+ if (reused && sha256(reused) === hash) parts.push(reused);
527
+ else missing.add(hash);
528
+ }
529
+ if (missing.size > 0) {
530
+ json(res, { ok: false, missing: [...missing] });
531
+ return;
532
+ }
533
+ const body = Buffer.concat(parts);
428
534
  try {
429
535
  const revision = await services.commitProjectMutation(req, [{ path, content: body }]);
430
- json(res, { ok: true, bytes: body.byteLength, revision: revision?.revision ?? null });
536
+ heldChunks.delete(path);
537
+ const byHash = new Map<string, { offset: number; length: number }>();
538
+ let offset = 0;
539
+ for (const [hash, length] of listed) {
540
+ byHash.set(hash, { offset, length });
541
+ offset += length;
542
+ }
543
+ const written = await stat(join(root, path)).catch(() => null);
544
+ if (written && written.size === body.byteLength)
545
+ committedChunks.set(path, { size: written.size, mtimeMs: written.mtimeMs, byHash });
546
+ else committedChunks.delete(path);
547
+ json(res, {
548
+ ok: true,
549
+ bytes: body.byteLength,
550
+ sent: listed.reduce((sum, [hash, length]) => sum + (held.has(hash) ? length : 0), 0),
551
+ revision: revision?.revision ?? null,
552
+ });
431
553
  } catch (error) {
432
554
  services.answerProjectMutationError(res, error);
433
555
  }