@volter/blender-engine 0.1.61 → 0.1.63
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/blender-emscripten-engine.mts +20 -9
- package/browser/blender-engine.mts +1 -0
- package/browser/pull-job.mts +3 -1
- package/browser/runtime.ts +27 -11
- package/browser/session.py +9 -2
- package/browser/worker.ts +17 -12
- package/package.json +1 -1
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `session.py` chmods its own root as its first act.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { checkpointStream } from './pull-job.mts';
|
|
30
31
|
import { writeStreamedFile, type StreamFileSystem } from './stream-file.mts';
|
|
31
32
|
import {
|
|
32
33
|
artifactUrl,
|
|
@@ -149,14 +150,17 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
|
|
|
149
150
|
*/
|
|
150
151
|
const ARTIFACT_CACHE = 'volter-blender-artifacts';
|
|
151
152
|
|
|
152
|
-
async function cachedArtifact(file: string, digest: string | undefined): Promise<Response> {
|
|
153
|
+
async function cachedArtifact(file: string, digest: string | undefined, checkpoint: () => Promise<void>): Promise<Response> {
|
|
154
|
+
const bounded = (response: Response): Response => response.ok && response.body
|
|
155
|
+
? new Response(checkpointStream(response.body, checkpoint), { status: response.status, headers: response.headers })
|
|
156
|
+
: response;
|
|
153
157
|
const url = artifactUrl(file);
|
|
154
|
-
if (!digest || typeof caches === 'undefined') return fetch(url);
|
|
158
|
+
if (!digest || typeof caches === 'undefined') return bounded(await fetch(url));
|
|
155
159
|
const key = `${url}?sha256=${digest}`;
|
|
156
160
|
const cache = await caches.open(ARTIFACT_CACHE);
|
|
157
161
|
const hit = await cache.match(key);
|
|
158
|
-
if (hit) return hit;
|
|
159
|
-
const response = await fetch(url);
|
|
162
|
+
if (hit) return bounded(hit);
|
|
163
|
+
const response = bounded(await fetch(url));
|
|
160
164
|
if (!response.ok) return response;
|
|
161
165
|
// One build's bytes per file: an older build's are dropped as this one lands.
|
|
162
166
|
for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
|
|
@@ -177,15 +181,18 @@ export async function startEmscriptenBlenderEngine(
|
|
|
177
181
|
);
|
|
178
182
|
const glueUrl = artifactUrl('blender_browser.js');
|
|
179
183
|
const digests = status.digests ?? {};
|
|
184
|
+
const checkpoint = async (phase: string): Promise<void> => { await options.ask({ checkpoint: phase }); };
|
|
185
|
+
const artifact = (file: string) => cachedArtifact(file, digests[file], () => checkpoint(`artifact/${file}`));
|
|
180
186
|
// The `.data` package is handed to the glue whole (`getPreloadedPackage`),
|
|
181
187
|
// so it is read before the module starts; the wasm streams in beside it.
|
|
182
188
|
const [factory, preloaded] = await Promise.all([
|
|
183
189
|
loadFactory(glueUrl),
|
|
184
|
-
|
|
190
|
+
artifact('blender_browser.data').then(async (response) => {
|
|
185
191
|
if (!response.ok) throw new Error(`blender_browser.data: HTTP ${response.status}`);
|
|
186
192
|
return response.arrayBuffer();
|
|
187
193
|
}),
|
|
188
194
|
]);
|
|
195
|
+
await checkpoint('runtime-package-ready');
|
|
189
196
|
let bootError: unknown = null;
|
|
190
197
|
const started = performance.now();
|
|
191
198
|
let readyLine: string | null = null;
|
|
@@ -221,9 +228,10 @@ export async function startEmscriptenBlenderEngine(
|
|
|
221
228
|
getPreloadedPackage: () => preloaded,
|
|
222
229
|
instantiateWasm: (imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void) => {
|
|
223
230
|
void (async () => {
|
|
224
|
-
const response = await
|
|
231
|
+
const response = await artifact('blender_browser.wasm');
|
|
225
232
|
if (!response.ok) throw new Error(`blender_browser.wasm: HTTP ${response.status}`);
|
|
226
233
|
const { instance, module: compiled } = await WebAssembly.instantiateStreaming(response, imports);
|
|
234
|
+
await checkpoint('wasm-instantiated');
|
|
227
235
|
receive(instance, compiled);
|
|
228
236
|
})().catch((error: unknown) => { bootError = error; });
|
|
229
237
|
return {};
|
|
@@ -273,6 +281,7 @@ export async function startEmscriptenBlenderEngine(
|
|
|
273
281
|
}).finally(() => {
|
|
274
282
|
(WebAssembly as { Memory: unknown }).Memory = Memory;
|
|
275
283
|
});
|
|
284
|
+
await checkpoint('runtime-initialized');
|
|
276
285
|
// `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
|
|
277
286
|
// long before the session exists. The ready line is what says it does.
|
|
278
287
|
while (readyLine === null) {
|
|
@@ -337,7 +346,8 @@ export async function startEmscriptenBlenderEngine(
|
|
|
337
346
|
|
|
338
347
|
const files = moduleFiles(module);
|
|
339
348
|
FS.chmod('/bw/datafiles', 0o755);
|
|
340
|
-
await
|
|
349
|
+
await checkpoint('python-ready');
|
|
350
|
+
await mountEssentials(files, digests['essentials.bin'], checkpoint);
|
|
341
351
|
const { request } = openSessionChannel(files, options);
|
|
342
352
|
|
|
343
353
|
return {
|
|
@@ -364,9 +374,9 @@ export async function startEmscriptenBlenderEngine(
|
|
|
364
374
|
/** Assets are data, not a second engine. Ship them separately so a data update
|
|
365
375
|
* does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
|
|
366
376
|
* checked before anything enters Blender's filesystem. */
|
|
367
|
-
async function mountEssentials(files: BlenderFiles, digest: string | undefined): Promise<void> {
|
|
377
|
+
async function mountEssentials(files: BlenderFiles, digest: string | undefined, checkpoint: (phase: string) => Promise<void>): Promise<void> {
|
|
368
378
|
const [indexResponse, payloadResponse] = await Promise.all([
|
|
369
|
-
fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest),
|
|
379
|
+
fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest, () => checkpoint('artifact/essentials.bin')),
|
|
370
380
|
]);
|
|
371
381
|
if (!indexResponse.ok || !payloadResponse.ok)
|
|
372
382
|
throw new Error(`Blender Essentials assets are missing (${indexResponse.status}/${payloadResponse.status})`);
|
|
@@ -380,6 +390,7 @@ async function mountEssentials(files: BlenderFiles, digest: string | undefined):
|
|
|
380
390
|
if (payload.byteLength !== index.bytes || hash !== index.sha256)
|
|
381
391
|
throw new Error('Blender Essentials payload does not match its source manifest');
|
|
382
392
|
for (const file of index.files) {
|
|
393
|
+
await checkpoint('essentials-file');
|
|
383
394
|
if (!file.path || file.path.includes('\\') || file.path.split('/').some(p => !p || p === '.' || p === '..') ||
|
|
384
395
|
!Number.isInteger(file.offset) || !Number.isInteger(file.bytes) ||
|
|
385
396
|
file.offset < 0 || file.bytes < 0 || file.offset + file.bytes > payload.byteLength)
|
|
@@ -258,6 +258,7 @@ export async function artifactStatus(): Promise<BlenderArtifactStatus> {
|
|
|
258
258
|
*/
|
|
259
259
|
export async function startBlenderEngine(options: BlenderEngineOptions): Promise<BlenderEngine> {
|
|
260
260
|
const status = await artifactStatus();
|
|
261
|
+
await options.ask({ checkpoint: 'artifact-status' });
|
|
261
262
|
if (!status.available)
|
|
262
263
|
throw new Error(
|
|
263
264
|
`The headless Blender WebAssembly build is not served by this editor: ${status.missing.join('; ')}`,
|
package/browser/pull-job.mts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/** A cooperative job advances only when its owner asks for its next unit.
|
|
2
2
|
* Checkpoints park the producer; they do not renew a timer while work runs. */
|
|
3
3
|
export type PullResult<T> = { load: 'continue'; token: string; phase: string } | { load: 'done'; value: T };
|
|
4
|
+
let nextJob = 0;
|
|
4
5
|
export class PullJob<T> {
|
|
6
|
+
private readonly job = ++nextJob;
|
|
5
7
|
private started = false;
|
|
6
8
|
private finished = false;
|
|
7
9
|
private sequence = 0;
|
|
@@ -17,7 +19,7 @@ export class PullJob<T> {
|
|
|
17
19
|
this.started = true;
|
|
18
20
|
void Promise.resolve().then(() => this.work(phase => new Promise<void>(resolve => {
|
|
19
21
|
this.resume = resolve;
|
|
20
|
-
this.expected = `load:${++this.sequence}`;
|
|
22
|
+
this.expected = `load:${this.job}:${++this.sequence}`;
|
|
21
23
|
const waiter = this.waiter!; this.waiter = null;
|
|
22
24
|
waiter.resolve({ load: 'continue', token: this.expected, phase });
|
|
23
25
|
}))).then(value => {
|
package/browser/runtime.ts
CHANGED
|
@@ -159,7 +159,7 @@ export class BlenderRuntime {
|
|
|
159
159
|
#terminated = false;
|
|
160
160
|
#dirty = false;
|
|
161
161
|
readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
|
|
162
|
-
if (!this.#dirty && this.#
|
|
162
|
+
if (!this.#dirty && this.#acceptedRequests === 0) return;
|
|
163
163
|
event.preventDefault();
|
|
164
164
|
event.returnValue = '';
|
|
165
165
|
};
|
|
@@ -219,6 +219,8 @@ export class BlenderRuntime {
|
|
|
219
219
|
for (const id of [...this.#pending.keys()]) this.#settled(id);
|
|
220
220
|
for (const pending of this.#pending.values()) pending.reject(error);
|
|
221
221
|
this.#pending.clear();
|
|
222
|
+
// Queued logical operations must reject too; this worker cannot answer them.
|
|
223
|
+
this.terminate();
|
|
222
224
|
};
|
|
223
225
|
}
|
|
224
226
|
|
|
@@ -259,7 +261,7 @@ export class BlenderRuntime {
|
|
|
259
261
|
);
|
|
260
262
|
this.#project = project;
|
|
261
263
|
this.#document ??= document ?? 'models/model.blend';
|
|
262
|
-
this.#started ??= this.#
|
|
264
|
+
this.#started ??= this.#request({
|
|
263
265
|
op: 'start',
|
|
264
266
|
project,
|
|
265
267
|
...(document === undefined ? {} : { document }),
|
|
@@ -267,15 +269,6 @@ export class BlenderRuntime {
|
|
|
267
269
|
return this.#started;
|
|
268
270
|
}
|
|
269
271
|
|
|
270
|
-
async #load(request: Extract<Request, { op: 'start' }>): Promise<RuntimeStart> {
|
|
271
|
-
let result = await this.#request(request) as PullResult<RuntimeStart>;
|
|
272
|
-
while (result?.load === 'continue') {
|
|
273
|
-
this.#loadBoundary = result.phase;
|
|
274
|
-
result = await this.#request({ op: 'load-next', token: result.token }) as PullResult<RuntimeStart>;
|
|
275
|
-
}
|
|
276
|
-
return result?.load === 'done' ? result.value : result as unknown as RuntimeStart;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
272
|
#ready(): Promise<RuntimeStart> {
|
|
280
273
|
if (this.#project === null)
|
|
281
274
|
return Promise.reject(
|
|
@@ -566,9 +559,32 @@ export class BlenderRuntime {
|
|
|
566
559
|
this.#lastCallWindow = { start: started, end: started + elapsed };
|
|
567
560
|
}
|
|
568
561
|
|
|
562
|
+
#acceptedRequests = 0;
|
|
563
|
+
#requestTail: Promise<void> = Promise.resolve();
|
|
569
564
|
#request(request: Request, shutdown = false): Promise<unknown> {
|
|
570
565
|
if (this.#terminated || (this.#stopping && !shutdown))
|
|
571
566
|
return Promise.reject(new Error('The Blender session is stopping or terminated'));
|
|
567
|
+
const began = performance.now();
|
|
568
|
+
this.#acceptedRequests++;
|
|
569
|
+
const answer = this.#requestTail.then(async () => {
|
|
570
|
+
this.#loadBoundary = request.op;
|
|
571
|
+
let result = await this.#wireRequest(request) as PullResult<unknown>;
|
|
572
|
+
while (result?.load === 'continue') {
|
|
573
|
+
this.#loadBoundary = result.phase;
|
|
574
|
+
result = await this.#wireRequest({ op: 'load-next', token: result.token }) as PullResult<unknown>;
|
|
575
|
+
}
|
|
576
|
+
return result?.load === 'done' ? result.value : result;
|
|
577
|
+
}).finally(() => {
|
|
578
|
+
this.#acceptedRequests--;
|
|
579
|
+
const totalMs = Math.round(performance.now() - began);
|
|
580
|
+
try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${request.op} totalMs=${totalMs}`); } catch { /* diagnostics cannot poison the queue */ }
|
|
581
|
+
});
|
|
582
|
+
this.#requestTail = answer.then(() => {}, () => {});
|
|
583
|
+
return answer;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
#wireRequest(request: Request): Promise<unknown> {
|
|
587
|
+
if (this.#terminated) return Promise.reject(new Error('The Blender session is terminated'));
|
|
572
588
|
const id = ++this.#nextId;
|
|
573
589
|
return new Promise((resolve, reject) => {
|
|
574
590
|
this.#pending.set(id, { resolve, reject });
|
package/browser/session.py
CHANGED
|
@@ -4421,8 +4421,11 @@ def rna_rig(object_name=None):
|
|
|
4421
4421
|
subjects = [obj for obj in bpy.context.view_layer.objects
|
|
4422
4422
|
if obj.type == "MESH" and obj.data is not None
|
|
4423
4423
|
and obj.find_armature() is not None]
|
|
4424
|
-
|
|
4425
|
-
|
|
4424
|
+
rigs = []
|
|
4425
|
+
for obj in subjects:
|
|
4426
|
+
_asked({"checkpoint": "rig-object"})
|
|
4427
|
+
rigs.append(_rig_of(obj))
|
|
4428
|
+
return {"frame": scene_frame, "named": object_name, "rigs": rigs}
|
|
4426
4429
|
|
|
4427
4430
|
|
|
4428
4431
|
def _rig_of(obj):
|
|
@@ -4506,7 +4509,11 @@ def _rig_of(obj):
|
|
|
4506
4509
|
truncated = 0
|
|
4507
4510
|
unweighted = 0
|
|
4508
4511
|
most = 0
|
|
4512
|
+
checkpoint_at = time.monotonic()
|
|
4509
4513
|
for i, vertex in enumerate(mesh.vertices):
|
|
4514
|
+
if i % 1024 == 0 and time.monotonic() - checkpoint_at >= 0.05:
|
|
4515
|
+
_asked({"checkpoint": "rig-weights"})
|
|
4516
|
+
checkpoint_at = time.monotonic()
|
|
4510
4517
|
pairs = []
|
|
4511
4518
|
for element in vertex.groups:
|
|
4512
4519
|
bone = group_to_bone.get(int(element.group))
|
package/browser/worker.ts
CHANGED
|
@@ -573,17 +573,22 @@ async function listSessionFiles(files: BlenderFiles, root: string): Promise<File
|
|
|
573
573
|
return out;
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
function pullWork(label: string, work: () => Promise<unknown>): Promise<unknown> {
|
|
577
|
+
if (loadCheckpoint) throw new Error('The current Blender operation must finish first');
|
|
578
|
+
loadJob = new PullJob(async checkpoint => {
|
|
579
|
+
loadCheckpoint = checkpoint;
|
|
580
|
+
const began = performance.now();
|
|
581
|
+
try { return await work(); }
|
|
582
|
+
finally { loadCheckpoint = null; log('log', `@@VOLTER-WORK op=${label} totalMs=${Math.round(performance.now() - began)}`); }
|
|
583
|
+
});
|
|
584
|
+
return loadJob.step();
|
|
585
|
+
}
|
|
586
|
+
|
|
576
587
|
async function handle(request: WorkerRequest): Promise<unknown> {
|
|
577
588
|
switch (request.op) {
|
|
578
589
|
case 'start': {
|
|
579
590
|
if (loadJob) throw new Error('The Blender load has already started');
|
|
580
|
-
|
|
581
|
-
loadCheckpoint = checkpoint;
|
|
582
|
-
const began = performance.now();
|
|
583
|
-
try { return await start(request.project, request.document); }
|
|
584
|
-
finally { loadCheckpoint = null; log('log', `@@VOLTER-LOAD totalMs=${Math.round(performance.now() - began)}`); }
|
|
585
|
-
});
|
|
586
|
-
return loadJob.step();
|
|
591
|
+
return pullWork('start', () => start(request.project, request.document));
|
|
587
592
|
}
|
|
588
593
|
case 'load-next':
|
|
589
594
|
if (!loadJob) throw new Error('No Blender load to continue');
|
|
@@ -632,7 +637,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
632
637
|
case 'present':
|
|
633
638
|
// Straight through to `session.py`'s own `present` op — the worker adds
|
|
634
639
|
// nothing, and a capture-less present answers `{ presented, revision }`.
|
|
635
|
-
return ask({ op: 'present' });
|
|
640
|
+
return pullWork('present', () => ask({ op: 'present' }));
|
|
636
641
|
case 'scene-info':
|
|
637
642
|
return session.sceneInfo();
|
|
638
643
|
case 'object-info':
|
|
@@ -698,10 +703,10 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
698
703
|
// because `collection` crossed `runtime.ts` and `session.py` and never
|
|
699
704
|
// this switch, with no error anywhere.
|
|
700
705
|
case 'rig':
|
|
701
|
-
return ask({
|
|
706
|
+
return pullWork('rig', () => ask({
|
|
702
707
|
op: 'rig',
|
|
703
708
|
...(request.object === undefined ? {} : { object: request.object }),
|
|
704
|
-
});
|
|
709
|
+
}));
|
|
705
710
|
case 'action-clip':
|
|
706
711
|
return ask({
|
|
707
712
|
op: 'action-clip',
|
|
@@ -791,7 +796,7 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|
|
791
796
|
async function answerRequest(request: WorkerRequest): Promise<void> {
|
|
792
797
|
try {
|
|
793
798
|
const result = await handle(request);
|
|
794
|
-
if (documentDirty) await saveDocument();
|
|
799
|
+
if (!loadCheckpoint && documentDirty) await saveDocument();
|
|
795
800
|
await reportHistory();
|
|
796
801
|
post({ id: request.id, result });
|
|
797
802
|
} catch (error) {
|
|
@@ -811,7 +816,7 @@ async function answerRequest(request: WorkerRequest): Promise<void> {
|
|
|
811
816
|
}
|
|
812
817
|
|
|
813
818
|
async function reportHistory(): Promise<void> {
|
|
814
|
-
if (!engine || !session) return;
|
|
819
|
+
if (loadCheckpoint || !engine || !session) return;
|
|
815
820
|
const entries = await engine.request({ op: 'history-events' }) as import('./protocol').NativeHistoryEntry[];
|
|
816
821
|
if (entries.length) post({ op: 'history', entries });
|
|
817
822
|
}
|