@volter/editor-blender 0.1.46 → 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.
- package/dist-node/serving.mjs +105 -5
- package/package.json +6 -6
- package/serving/blender-routes.ts +129 -7
package/dist-node/serving.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
74
|
-
"@volter/editor-threejs": "0.5.
|
|
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.
|
|
79
|
-
"@volter/editor-project": "0.5.
|
|
80
|
-
"@volter/editor-sdk": "0.5.
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
}
|