@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.
- package/browser/document-chunks.mts +72 -0
- package/browser/runtime.ts +6 -0
- package/browser/worker.ts +50 -10
- package/package.json +1 -1
|
@@ -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/runtime.ts
CHANGED
|
@@ -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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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> {
|