@volter/blender-engine 0.1.112 → 0.1.113
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.
|
@@ -31,6 +31,7 @@ import { checkpointStream } from './pull-job.mts';
|
|
|
31
31
|
import { writeStreamedFile, type StreamFileSystem } from './stream-file.mts';
|
|
32
32
|
import {
|
|
33
33
|
artifactUrl,
|
|
34
|
+
startupOperation,
|
|
34
35
|
type BlenderArtifactStatus,
|
|
35
36
|
type BlenderEngine,
|
|
36
37
|
type BlenderEngineOptions,
|
|
@@ -83,19 +84,19 @@ type BlenderModuleFactory = (options: Record<string, unknown>) => Promise<Blende
|
|
|
83
84
|
/** The glue, evaluated in this worker. It is a UMD bundle, so it is given the
|
|
84
85
|
* `module`/`exports` pair it looks for and hands back the factory. */
|
|
85
86
|
async function loadFactory(glueUrl: string): Promise<BlenderModuleFactory> {
|
|
86
|
-
const source = await fetch(glueUrl);
|
|
87
|
+
const source = await startupOperation(glueUrl, 'fetch glue', () => fetch(glueUrl));
|
|
87
88
|
if (!source.ok) throw new Error(`${glueUrl}: HTTP ${source.status}`);
|
|
88
|
-
const text = await source.text();
|
|
89
|
+
const text = await startupOperation(glueUrl, 'read glue body', () => source.text());
|
|
89
90
|
const container = { exports: {} as { default?: BlenderModuleFactory } };
|
|
90
|
-
const evaluate = new Function(
|
|
91
|
+
const evaluate = await startupOperation(glueUrl, 'compile glue', () => new Function(
|
|
91
92
|
'module',
|
|
92
93
|
'exports',
|
|
93
94
|
'define',
|
|
94
95
|
`${text}\nreturn typeof createBlenderModule === 'function' ? createBlenderModule : module.exports;`,
|
|
95
|
-
) as (module: unknown, exports: unknown, define: unknown) => BlenderModuleFactory;
|
|
96
|
-
const factory = evaluate(container, container.exports, undefined);
|
|
96
|
+
) as (module: unknown, exports: unknown, define: unknown) => BlenderModuleFactory);
|
|
97
|
+
const factory = await startupOperation(glueUrl, 'evaluate glue', () => evaluate(container, container.exports, undefined));
|
|
97
98
|
if (typeof factory !== 'function')
|
|
98
|
-
throw new Error(
|
|
99
|
+
throw new Error(`${glueUrl}: blender_browser.js did not produce a module factory`);
|
|
99
100
|
return factory;
|
|
100
101
|
}
|
|
101
102
|
|
|
@@ -155,17 +156,21 @@ async function cachedArtifact(file: string, digest: string | undefined, checkpoi
|
|
|
155
156
|
? new Response(checkpointStream(response.body, checkpoint), { status: response.status, headers: response.headers })
|
|
156
157
|
: response;
|
|
157
158
|
const url = artifactUrl(file);
|
|
158
|
-
|
|
159
|
+
const fetchArtifact = () => startupOperation(url, 'fetch artifact', () => fetch(url));
|
|
160
|
+
if (!digest || typeof caches === 'undefined') return bounded(await fetchArtifact());
|
|
159
161
|
const key = `${url}?sha256=${digest}`;
|
|
160
|
-
const cache = await caches.open(ARTIFACT_CACHE);
|
|
161
|
-
const hit = await cache.match(key);
|
|
162
|
+
const cache = await startupOperation(url, 'open artifact cache', () => caches.open(ARTIFACT_CACHE));
|
|
163
|
+
const hit = await startupOperation(url, 'read artifact cache', () => cache.match(key));
|
|
162
164
|
if (hit) return bounded(hit);
|
|
163
|
-
const response = bounded(await
|
|
165
|
+
const response = bounded(await fetchArtifact());
|
|
164
166
|
if (!response.ok) return response;
|
|
165
167
|
// One build's bytes per file: an older build's are dropped as this one lands.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
await startupOperation(url, 'prune previous artifact cache entries', async () => {
|
|
169
|
+
for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
|
|
170
|
+
});
|
|
171
|
+
const body = await startupOperation(url, 'read artifact body for cache', () => response.blob());
|
|
172
|
+
const kept = new Response(body, { headers: { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' } });
|
|
173
|
+
await startupOperation(url, 'write artifact cache', () => cache.put(key, kept.clone()));
|
|
169
174
|
return kept;
|
|
170
175
|
}
|
|
171
176
|
|
|
@@ -188,8 +193,9 @@ export async function startEmscriptenBlenderEngine(
|
|
|
188
193
|
const [factory, preloaded] = await Promise.all([
|
|
189
194
|
loadFactory(glueUrl),
|
|
190
195
|
artifact('blender_browser.data').then(async (response) => {
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
const url = artifactUrl('blender_browser.data');
|
|
197
|
+
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
|
|
198
|
+
return startupOperation(url, 'read data package body', () => response.arrayBuffer());
|
|
193
199
|
}),
|
|
194
200
|
]);
|
|
195
201
|
await checkpoint('runtime-package-ready');
|
|
@@ -222,15 +228,16 @@ export async function startEmscriptenBlenderEngine(
|
|
|
222
228
|
return made;
|
|
223
229
|
} as unknown as typeof WebAssembly.Memory;
|
|
224
230
|
const heap = (): Uint8Array => (memory ? new Uint8Array(memory.buffer) : module.HEAPU8);
|
|
225
|
-
const module = await factory({
|
|
231
|
+
const module = await startupOperation(glueUrl, 'initialize glue runtime', () => factory({
|
|
226
232
|
arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
|
|
227
233
|
locateFile: (file: string) => artifactUrl(file),
|
|
228
234
|
getPreloadedPackage: () => preloaded,
|
|
229
235
|
instantiateWasm: (imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void) => {
|
|
230
236
|
void (async () => {
|
|
231
237
|
const response = await artifact('blender_browser.wasm');
|
|
232
|
-
|
|
233
|
-
|
|
238
|
+
const url = artifactUrl('blender_browser.wasm');
|
|
239
|
+
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
|
|
240
|
+
const { instance, module: compiled } = await startupOperation(url, 'read and instantiate Wasm stream', () => WebAssembly.instantiateStreaming(response, imports));
|
|
234
241
|
await checkpoint('wasm-instantiated');
|
|
235
242
|
receive(instance, compiled);
|
|
236
243
|
})().catch((error: unknown) => { bootError = error; });
|
|
@@ -280,7 +287,7 @@ export async function startEmscriptenBlenderEngine(
|
|
|
280
287
|
],
|
|
281
288
|
}).finally(() => {
|
|
282
289
|
(WebAssembly as { Memory: unknown }).Memory = Memory;
|
|
283
|
-
});
|
|
290
|
+
}));
|
|
284
291
|
await checkpoint('runtime-initialized');
|
|
285
292
|
// `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
|
|
286
293
|
// long before the session exists. The ready line is what says it does.
|
|
@@ -375,16 +382,18 @@ export async function startEmscriptenBlenderEngine(
|
|
|
375
382
|
* does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
|
|
376
383
|
* checked before anything enters Blender's filesystem. */
|
|
377
384
|
async function mountEssentials(files: BlenderFiles, digest: string | undefined, checkpoint: (phase: string) => Promise<void>): Promise<void> {
|
|
385
|
+
const indexUrl = artifactUrl('essentials.json');
|
|
386
|
+
const payloadUrl = artifactUrl('essentials.bin');
|
|
378
387
|
const [indexResponse, payloadResponse] = await Promise.all([
|
|
379
|
-
|
|
388
|
+
startupOperation(indexUrl, 'fetch Essentials index', () => fetch(indexUrl)), cachedArtifact('essentials.bin', digest, () => checkpoint('artifact/essentials.bin')),
|
|
380
389
|
]);
|
|
381
390
|
if (!indexResponse.ok || !payloadResponse.ok)
|
|
382
|
-
throw new Error(`Blender Essentials assets are missing (${indexResponse.status}
|
|
383
|
-
const index = await indexResponse.json() as {
|
|
391
|
+
throw new Error(`Blender Essentials assets are missing (${indexUrl}: HTTP ${indexResponse.status}; ${payloadUrl}: HTTP ${payloadResponse.status})`);
|
|
392
|
+
const index = await startupOperation(indexUrl, 'decode Essentials index JSON', () => indexResponse.json()) as {
|
|
384
393
|
bytes: number; sha256: string;
|
|
385
394
|
files: { path: string; offset: number; bytes: number; sha256: string }[];
|
|
386
395
|
};
|
|
387
|
-
const payload = await payloadResponse.arrayBuffer();
|
|
396
|
+
const payload = await startupOperation(payloadUrl, 'read Essentials payload body', () => payloadResponse.arrayBuffer());
|
|
388
397
|
const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', payload)),
|
|
389
398
|
(byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
390
399
|
if (payload.byteLength !== index.bytes || hash !== index.sha256)
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
* implementation over {@link BlenderFiles}.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
+
import { describeThrown } from './describe-thrown.mts';
|
|
47
|
+
|
|
46
48
|
export const SESSION_ROOT = '/work/.volter-session';
|
|
47
49
|
export const SESSION_SCRIPT = `${SESSION_ROOT}/session.py`;
|
|
48
50
|
|
|
@@ -210,6 +212,19 @@ export function artifactUrl(file: string): string {
|
|
|
210
212
|
return new URL(`${ARTIFACT_BASE}/${file}`, import.meta.url).href;
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
/** Attribute a startup failure without repeating the operation. The worker's
|
|
216
|
+
* document may never publish, so this message must survive as the diagnosis.
|
|
217
|
+
* For an import, url is the importing module; the browser's resolved failing
|
|
218
|
+
* module URL, when supplied, stays in the original cause. */
|
|
219
|
+
export async function startupOperation<T>(url: string, operation: string, run: () => T | Promise<T>): Promise<T> {
|
|
220
|
+
try {
|
|
221
|
+
return await run();
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
const detail = describeThrown(cause, false);
|
|
224
|
+
throw new Error(`Blender startup ${operation} (${url}): ${detail}`, { cause });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
213
228
|
/** What the editor's artifact door answers. `blender-wasm-artifact.ts` owns it. */
|
|
214
229
|
export interface BlenderArtifactStatus {
|
|
215
230
|
available: boolean;
|
|
@@ -231,7 +246,8 @@ export interface BlenderArtifactStatus {
|
|
|
231
246
|
}
|
|
232
247
|
|
|
233
248
|
export async function artifactStatus(): Promise<BlenderArtifactStatus> {
|
|
234
|
-
const
|
|
249
|
+
const url = artifactUrl('status');
|
|
250
|
+
const answer = await startupOperation(url, 'fetch status', () => fetch(url));
|
|
235
251
|
if (!answer.ok)
|
|
236
252
|
return {
|
|
237
253
|
available: false,
|
|
@@ -241,7 +257,7 @@ export async function artifactStatus(): Promise<BlenderArtifactStatus> {
|
|
|
241
257
|
encoded: {},
|
|
242
258
|
missing: [`${ARTIFACT_BASE}/status answered ${answer.status}`],
|
|
243
259
|
};
|
|
244
|
-
return (
|
|
260
|
+
return startupOperation(url, 'decode status JSON', () => answer.json()) as Promise<BlenderArtifactStatus>;
|
|
245
261
|
}
|
|
246
262
|
|
|
247
263
|
/**
|
|
@@ -264,10 +280,10 @@ export async function startBlenderEngine(options: BlenderEngineOptions): Promise
|
|
|
264
280
|
`The headless Blender WebAssembly build is not served by this editor: ${status.missing.join('; ')}`,
|
|
265
281
|
);
|
|
266
282
|
if (status.skew === 'wali') {
|
|
267
|
-
const { startWaliBlenderEngine } = await import('./blender-wali-engine.mts');
|
|
283
|
+
const { startWaliBlenderEngine } = await startupOperation(import.meta.url, 'import WALI engine module from', () => import('./blender-wali-engine.mts'));
|
|
268
284
|
return startWaliBlenderEngine(options, status);
|
|
269
285
|
}
|
|
270
|
-
const { startEmscriptenBlenderEngine } = await import('./blender-emscripten-engine.mts');
|
|
286
|
+
const { startEmscriptenBlenderEngine } = await startupOperation(import.meta.url, 'import Emscripten engine module from', () => import('./blender-emscripten-engine.mts'));
|
|
271
287
|
return startEmscriptenBlenderEngine(options, status);
|
|
272
288
|
}
|
|
273
289
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Anything thrown, rendered so the message SURVIVES the worker boundary.
|
|
2
|
+
*
|
|
3
|
+
* String(object) erased a prior real diagnostic as [object Object]. Preserve
|
|
4
|
+
* named messages or JSON fields, with a safe fallback for cyclic values.
|
|
5
|
+
* Startup wrappers omit Error stacks from their user-visible message; worker
|
|
6
|
+
* replies keep them as before. */
|
|
7
|
+
export function describeThrown(error: unknown, includeStack = true): string {
|
|
8
|
+
if (error instanceof Error)
|
|
9
|
+
return (includeStack ? error.stack : undefined) ?? `${error.name}: ${error.message}`;
|
|
10
|
+
if (typeof error === 'object' && error !== null) {
|
|
11
|
+
const named = error as { name?: unknown; message?: unknown };
|
|
12
|
+
if (typeof named.message === 'string') {
|
|
13
|
+
return typeof named.name === 'string' ? `${named.name}: ${named.message}` : named.message;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return JSON.stringify(error) ?? Object.prototype.toString.call(error);
|
|
17
|
+
} catch {
|
|
18
|
+
return Object.prototype.toString.call(error);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return String(error);
|
|
22
|
+
}
|
package/browser/worker.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { PullJob, checkpointStream } from './pull-job.mts';
|
|
2
2
|
import { sendFrameValue } from './frame-stream.mts';
|
|
3
|
+
import { describeThrown } from './describe-thrown.mts';
|
|
4
|
+
export { describeThrown } from './describe-thrown.mts';
|
|
3
5
|
/**
|
|
4
6
|
* THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
|
|
5
7
|
* 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
|
|
@@ -39,7 +41,7 @@ import { sendFrameValue } from './frame-stream.mts';
|
|
|
39
41
|
*/
|
|
40
42
|
/// <reference types="vite/client" />
|
|
41
43
|
|
|
42
|
-
import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
|
|
44
|
+
import { startupOperation, type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
|
|
43
45
|
import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
|
|
44
46
|
import { documentChunks } from './document-chunks.mts';
|
|
45
47
|
import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
|
|
@@ -395,10 +397,11 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
395
397
|
}
|
|
396
398
|
|
|
397
399
|
async function blenderIsServed(): Promise<{ available: boolean; missing: string[] }> {
|
|
400
|
+
const url = '/__editor/blender-wasm/status';
|
|
398
401
|
try {
|
|
399
|
-
const answer = await
|
|
400
|
-
if (!answer.ok) return { available: false, missing: [
|
|
401
|
-
return
|
|
402
|
+
const answer = await startupOperation(url, 'fetch initial status', () => fetch(url));
|
|
403
|
+
if (!answer.ok) return { available: false, missing: [`${url}: HTTP ${answer.status}`] };
|
|
404
|
+
return await startupOperation(url, 'decode initial status JSON', () => answer.json()) as { available: boolean; missing: string[] };
|
|
402
405
|
} catch (error) {
|
|
403
406
|
return { available: false, missing: [String(error)] };
|
|
404
407
|
}
|
|
@@ -737,30 +740,6 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
737
740
|
throw new Error(`Unknown Blender worker request ${(request as { op: string }).op}`);
|
|
738
741
|
}
|
|
739
742
|
|
|
740
|
-
/** Anything thrown, rendered so the message SURVIVES the boundary.
|
|
741
|
-
*
|
|
742
|
-
* `error instanceof Error ? ... : String(error)` renders a thrown plain object
|
|
743
|
-
* as `[object Object]`, and that is the whole error a script sees: the worker
|
|
744
|
-
* answered `17-workshop-interior` seq 7 with exactly that, which named neither
|
|
745
|
-
* the operation nor the cause and left the next step with nothing to go on.
|
|
746
|
-
* A DOMException carries its name, and a plain object carries its own fields,
|
|
747
|
-
* so both are spelled out rather than coerced. */
|
|
748
|
-
export function describeThrown(error: unknown): string {
|
|
749
|
-
if (error instanceof Error) return error.stack ?? `${error.name}: ${error.message}`;
|
|
750
|
-
if (typeof error === 'object' && error !== null) {
|
|
751
|
-
const named = error as { name?: unknown; message?: unknown };
|
|
752
|
-
if (typeof named.message === 'string') {
|
|
753
|
-
return typeof named.name === 'string' ? `${named.name}: ${named.message}` : named.message;
|
|
754
|
-
}
|
|
755
|
-
try {
|
|
756
|
-
return JSON.stringify(error) ?? Object.prototype.toString.call(error);
|
|
757
|
-
} catch {
|
|
758
|
-
return Object.prototype.toString.call(error);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
return String(error);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
743
|
/**
|
|
765
744
|
* Tell the tab how big the engine's memory is now.
|
|
766
745
|
*
|