@volter/blender-engine 0.1.53 → 0.1.55
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/frame-stream.mts +71 -0
- package/browser/protocol.ts +2 -0
- package/browser/runtime.ts +38 -0
- package/browser/session-frame.mts +3 -4
- package/browser/three/blender-frame-columns.ts +1 -1
- package/browser/three/blender-runtime-view.ts +45 -2
- package/browser/worker.ts +32 -32
- package/package.json +1 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Bounded, acknowledged frame transport. One value (one mesh/image or the
|
|
2
|
+
* metadata frame) is assembled at a time; SHA-256 covers every chunk, including
|
|
3
|
+
* the metadata. Nothing is applied before all of that value has been verified. */
|
|
4
|
+
export const FRAME_CHUNK_BYTES = 1024 * 1024;
|
|
5
|
+
export type FrameChunk =
|
|
6
|
+
| { kind: 'begin'; lengths: number[] }
|
|
7
|
+
| { kind: 'bytes'; slot: number; offset: number; bytes: Uint8Array; sha256: string }
|
|
8
|
+
| { kind: 'end' };
|
|
9
|
+
const arrays = { Uint8Array, Int8Array, Uint16Array, Int16Array, Uint32Array, Int32Array, Float32Array, Float64Array };
|
|
10
|
+
const digest = async (bytes: Uint8Array): Promise<string> =>
|
|
11
|
+
Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array<ArrayBuffer>)), b => b.toString(16).padStart(2, '0')).join('');
|
|
12
|
+
|
|
13
|
+
export async function sendFrameValue(value: unknown, send: (chunk: FrameChunk) => Promise<unknown>): Promise<unknown> {
|
|
14
|
+
const buffers: Uint8Array[] = [];
|
|
15
|
+
const json = JSON.stringify(value, (_key, item: unknown) => {
|
|
16
|
+
if (!ArrayBuffer.isView(item)) return item;
|
|
17
|
+
const type = item.constructor.name;
|
|
18
|
+
if (!Object.hasOwn(arrays, type)) throw new Error(`Unsupported frame column ${type}`);
|
|
19
|
+
buffers.push(new Uint8Array(item.buffer, item.byteOffset, item.byteLength));
|
|
20
|
+
return { $frameColumn: buffers.length, type };
|
|
21
|
+
});
|
|
22
|
+
buffers.unshift(new TextEncoder().encode(json));
|
|
23
|
+
await send({ kind: 'begin', lengths: buffers.map(b => b.byteLength) });
|
|
24
|
+
for (const [slot, buffer] of buffers.entries()) {
|
|
25
|
+
for (let offset = 0; offset < buffer.byteLength; offset += FRAME_CHUNK_BYTES) {
|
|
26
|
+
const bytes = buffer.slice(offset, offset + FRAME_CHUNK_BYTES);
|
|
27
|
+
const sha256 = await digest(bytes);
|
|
28
|
+
await send({ kind: 'bytes', slot, offset, bytes, sha256 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return send({ kind: 'end' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class FrameStreamReader {
|
|
35
|
+
private buffers: Uint8Array[] | null = null;
|
|
36
|
+
private offsets: number[] = [];
|
|
37
|
+
reset(): void { this.buffers = null; this.offsets = []; }
|
|
38
|
+
async accept(chunk: FrameChunk): Promise<{ value: unknown } | undefined> {
|
|
39
|
+
try {
|
|
40
|
+
if (chunk.kind === 'begin') {
|
|
41
|
+
if (this.buffers) throw new Error('A frame value is already arriving');
|
|
42
|
+
if (!chunk.lengths.length || chunk.lengths.length > 4096 || chunk.lengths.reduce((sum, n) => sum + n, 0) > 2 * 1024 ** 3 || chunk.lengths.some(n => !Number.isSafeInteger(n) || n < 0 || n > 1024 ** 3))
|
|
43
|
+
throw new Error('Invalid frame buffer lengths');
|
|
44
|
+
this.buffers = chunk.lengths.map(n => new Uint8Array(n));
|
|
45
|
+
this.offsets = chunk.lengths.map(() => 0);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!this.buffers) throw new Error('Frame chunk arrived without its manifest');
|
|
49
|
+
if (chunk.kind === 'bytes') {
|
|
50
|
+
const buffer = this.buffers[chunk.slot];
|
|
51
|
+
if (!buffer || !Number.isSafeInteger(chunk.offset) || chunk.offset !== this.offsets[chunk.slot] || chunk.bytes.byteLength > FRAME_CHUNK_BYTES || !chunk.bytes.byteLength || chunk.offset + chunk.bytes.byteLength > buffer.byteLength)
|
|
52
|
+
throw new Error('Frame chunk is out of order or exceeds its bound');
|
|
53
|
+
if (await digest(chunk.bytes) !== chunk.sha256) throw new Error('Frame chunk digest mismatch');
|
|
54
|
+
buffer.set(chunk.bytes, chunk.offset); this.offsets[chunk.slot] = chunk.offset + chunk.bytes.byteLength;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (chunk.kind !== 'end') throw new Error('Unknown frame chunk');
|
|
58
|
+
const buffers = this.buffers;
|
|
59
|
+
if (buffers.some((b, i) => b.length !== this.offsets[i])) throw new Error('Frame value is incomplete');
|
|
60
|
+
const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffers[0]), (_key, item) => {
|
|
61
|
+
if (!item || typeof item !== 'object' || !('$frameColumn' in item)) return item;
|
|
62
|
+
const bytes = buffers[item.$frameColumn];
|
|
63
|
+
const Type = arrays[item.type as keyof typeof arrays];
|
|
64
|
+
if (!Number.isSafeInteger(item.$frameColumn) || item.$frameColumn < 1 || !bytes || !Object.hasOwn(arrays, item.type) || !Type || bytes.byteLength % Type.BYTES_PER_ELEMENT)
|
|
65
|
+
throw new Error('Invalid frame column descriptor');
|
|
66
|
+
return new Type(bytes.buffer as ArrayBuffer);
|
|
67
|
+
});
|
|
68
|
+
this.reset(); return { value };
|
|
69
|
+
} catch (error) { this.reset(); throw error; }
|
|
70
|
+
}
|
|
71
|
+
}
|
package/browser/protocol.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FrameChunk } from './frame-stream.mts';
|
|
1
2
|
/** Messages between the editor tab and its Blender worker. One session per
|
|
2
3
|
* worker; the model lives in the worker's Python and nowhere else. */
|
|
3
4
|
|
|
@@ -97,6 +98,7 @@ export type WorkerRequest =
|
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
export type WorkerReply =
|
|
101
|
+
| { op: 'frame-stream'; id: number; chunk: FrameChunk }
|
|
100
102
|
| { op: 'document-dirty'; dirty: boolean }
|
|
101
103
|
| { op: 'history'; entries: NativeHistoryEntry[] }
|
|
102
104
|
| { id: number; result: unknown }
|
package/browser/runtime.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FrameStreamReader } from './frame-stream.mts';
|
|
1
2
|
/**
|
|
2
3
|
* The tab-side handle on a Blender session: spawns the worker, forwards the
|
|
3
4
|
* four tool calls, and hands every presented frame to whoever displays it.
|
|
@@ -27,6 +28,8 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
|
|
|
27
28
|
type Request = DistributiveOmit<WorkerRequest, 'id'>;
|
|
28
29
|
|
|
29
30
|
export interface BlenderRuntimeOptions {
|
|
31
|
+
/** Stage one verified mesh/image without replacing the displayed frame. */
|
|
32
|
+
stage?(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
|
|
30
33
|
/** Operation-only diagnostics, never request payloads. The optional observer
|
|
31
34
|
* must not affect work, even if it throws. End calls balance overlapping work. */
|
|
32
35
|
work?(label: string): () => void;
|
|
@@ -478,6 +481,7 @@ export class BlenderRuntime {
|
|
|
478
481
|
|
|
479
482
|
/** Forced teardown for a lost session; explicit user stops must use stop(). */
|
|
480
483
|
terminate(): void {
|
|
484
|
+
this.#discardStagedFrame();
|
|
481
485
|
if (this.#terminated) return;
|
|
482
486
|
this.#terminated = true;
|
|
483
487
|
globalThis.removeEventListener?.('beforeunload', this.#beforeUnload);
|
|
@@ -570,8 +574,39 @@ export class BlenderRuntime {
|
|
|
570
574
|
} catch { return () => {}; }
|
|
571
575
|
}
|
|
572
576
|
|
|
577
|
+
readonly #frameStream = new FrameStreamReader();
|
|
578
|
+
#stagedIdentity: { session: string; revision: number } | null = null;
|
|
579
|
+
#discardStagedFrame(): void {
|
|
580
|
+
this.#frameStream.reset();
|
|
581
|
+
const part = this.#stagedIdentity; this.#stagedIdentity = null;
|
|
582
|
+
if (part) {
|
|
583
|
+
try { void Promise.resolve(this.#options.stage?.({ ...part, abort: true })).catch(() => {}); }
|
|
584
|
+
catch { /* Cleanup cannot replace the original operation failure. */ }
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
573
588
|
async #receive(reply: WorkerReply): Promise<void> {
|
|
574
589
|
if ('op' in reply) {
|
|
590
|
+
if (reply.op === 'frame-stream') {
|
|
591
|
+
try {
|
|
592
|
+
const decoded = await this.#frameStream.accept(reply.chunk);
|
|
593
|
+
if (decoded) {
|
|
594
|
+
const value = decoded.value as ({ op: 'stage'; session: string; revision: number } | Omit<Extract<WorkerReply, { op: 'present' }>, 'id'>);
|
|
595
|
+
if (value.op === 'present') {
|
|
596
|
+
await this.#receive({ ...value, id: reply.id } as WorkerReply);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (value.op !== 'stage' || !this.#options.stage) throw new Error('The presenter does not support staged Blender frames');
|
|
600
|
+
this.#stagedIdentity = { session: value.session, revision: value.revision };
|
|
601
|
+
await this.#options.stage(value);
|
|
602
|
+
}
|
|
603
|
+
this.#worker.postMessage({ op: 'present-result', id: reply.id } satisfies WorkerRequest);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
this.#discardStagedFrame();
|
|
606
|
+
this.#worker.postMessage({ op: 'present-result', id: reply.id, error: error instanceof Error ? error.message : String(error) } satisfies WorkerRequest);
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
575
610
|
if (reply.op === 'document-dirty') {
|
|
576
611
|
this.#dirty = reply.dirty;
|
|
577
612
|
return;
|
|
@@ -600,6 +635,7 @@ export class BlenderRuntime {
|
|
|
600
635
|
const end = this.#work('presenting worker frame');
|
|
601
636
|
try {
|
|
602
637
|
const answer = await this.#options.present(reply.frame, reply.description, reply.capture);
|
|
638
|
+
this.#stagedIdentity = null;
|
|
603
639
|
this.#worker.postMessage({
|
|
604
640
|
op: 'present-result',
|
|
605
641
|
id: reply.id,
|
|
@@ -609,6 +645,7 @@ export class BlenderRuntime {
|
|
|
609
645
|
: {}),
|
|
610
646
|
} satisfies WorkerRequest);
|
|
611
647
|
} catch (error) {
|
|
648
|
+
this.#discardStagedFrame();
|
|
612
649
|
this.#worker.postMessage({
|
|
613
650
|
op: 'present-result',
|
|
614
651
|
id: reply.id,
|
|
@@ -619,6 +656,7 @@ export class BlenderRuntime {
|
|
|
619
656
|
}
|
|
620
657
|
return;
|
|
621
658
|
}
|
|
659
|
+
if ('error' in reply) this.#discardStagedFrame();
|
|
622
660
|
const pending = this.#pending.get(reply.id);
|
|
623
661
|
if (!pending) return;
|
|
624
662
|
this.#pending.delete(reply.id);
|
|
@@ -91,15 +91,14 @@ function readColumn(arena: Uint8Array, name: string, descriptor: ColumnDescripto
|
|
|
91
91
|
* Every `{offset, length, dtype, count, stride}` in the frame, replaced by its
|
|
92
92
|
* typed array.
|
|
93
93
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* vertex array is single precision.
|
|
94
|
+
* Positions keep Blender's own precision. Widening a float column would
|
|
95
|
+
* double its transfer/storage without adding information.
|
|
97
96
|
*/
|
|
98
97
|
export function columnsToTypedArrays(arena: Uint8Array, frame: unknown): unknown {
|
|
99
98
|
const walk = (value: unknown, key: string): unknown => {
|
|
100
99
|
if (isColumnDescriptor(value)) {
|
|
101
100
|
const view = readColumn(arena, key, value);
|
|
102
|
-
return
|
|
101
|
+
return view;
|
|
103
102
|
}
|
|
104
103
|
if (Array.isArray(value)) return value.map((entry) => walk(entry, key));
|
|
105
104
|
if (typeof value === 'object' && value !== null) {
|
|
@@ -78,7 +78,7 @@ export const ATTRIBUTE_LAYOUT: Record<AttributeType, { size: number }> = {
|
|
|
78
78
|
/** Exactly what the draw reads off a presented mesh. */
|
|
79
79
|
export interface MeshColumns {
|
|
80
80
|
/** 3 * nv */
|
|
81
|
-
co: Float64Array;
|
|
81
|
+
co: Float32Array | Float64Array;
|
|
82
82
|
/** Blender-evaluated normals, 3 per corner, independent of UV splits. */
|
|
83
83
|
cornerNormal?: Float32Array | undefined;
|
|
84
84
|
/** 3 * nv, a deformed mesh's Generated coordinates in Blender's stored
|
|
@@ -184,7 +184,7 @@ const unchangedMeshSchema = z
|
|
|
184
184
|
*/
|
|
185
185
|
const columnsSchema = z
|
|
186
186
|
.object({
|
|
187
|
-
co: z.instanceof(Float64Array),
|
|
187
|
+
co: z.union([z.instanceof(Float32Array), z.instanceof(Float64Array)]),
|
|
188
188
|
cornerNormal: z.instanceof(Float32Array).optional(),
|
|
189
189
|
orco: z.instanceof(Float32Array).optional(),
|
|
190
190
|
faceStart: z.instanceof(Uint32Array),
|
|
@@ -1442,6 +1442,33 @@ export class BlenderRuntimeView {
|
|
|
1442
1442
|
group.visible = !this.capturing;
|
|
1443
1443
|
}
|
|
1444
1444
|
|
|
1445
|
+
private staged: { session: string; revision: number; meshes: Map<string, { signature: string; geometry: THREE.BufferGeometry }>; images: Map<string, z.infer<typeof frameImageSchema>> } | null = null;
|
|
1446
|
+
|
|
1447
|
+
/** Prepare one mesh as soon as its bounded transfer completes. Source
|
|
1448
|
+
* columns are released before the next mesh; displayed resources change
|
|
1449
|
+
* only when the final frame manifest is validated. */
|
|
1450
|
+
stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void {
|
|
1451
|
+
if (part.abort || (!part.mesh && !part.image)) {
|
|
1452
|
+
for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
|
|
1453
|
+
this.staged = part.abort ? null : { session: part.session, revision: part.revision, meshes: new Map(), images: new Map() };
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const staged = this.staged;
|
|
1457
|
+
if (!staged || staged.session !== part.session || staged.revision !== part.revision)
|
|
1458
|
+
throw new Error('Blender frame piece does not belong to the pending revision');
|
|
1459
|
+
if (part.mesh !== undefined) {
|
|
1460
|
+
const data = exportedMeshSchema.parse(part.piece);
|
|
1461
|
+
if (staged.meshes.has(part.mesh)) throw new Error('Duplicate Blender mesh piece');
|
|
1462
|
+
const signature = `revision:${data.revision}`;
|
|
1463
|
+
staged.meshes.set(part.mesh, { signature, geometry: geometryFromDrawArrays(drawArraysFromColumns({
|
|
1464
|
+
...data.columns, attributes: data.attributes as never, activeUv: data.activeUv, renderUv: data.renderUv,
|
|
1465
|
+
}, signature)) });
|
|
1466
|
+
} else if (part.image !== undefined) {
|
|
1467
|
+
if (staged.images.has(part.image)) throw new Error('Duplicate Blender image piece');
|
|
1468
|
+
staged.images.set(part.image, frameImageSchema.parse(part.piece));
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1445
1472
|
applyFrame(input: unknown) {
|
|
1446
1473
|
// WHAT THIS PRESENTER HELD BEFORE THIS FRAME, read before anything is
|
|
1447
1474
|
// applied, and carried back to the session in the present's answer.
|
|
@@ -1459,6 +1486,10 @@ export class BlenderRuntimeView {
|
|
|
1459
1486
|
const held =
|
|
1460
1487
|
this.frame === null ? null : { session: this.frame.session, revision: this.frame.revision };
|
|
1461
1488
|
const next = frameSchema.parse(input);
|
|
1489
|
+
const staged = this.staged;
|
|
1490
|
+
if (staged && (staged.session !== next.session || staged.revision !== next.revision))
|
|
1491
|
+
throw new Error('Blender frame manifest does not match its staged revision');
|
|
1492
|
+
if (staged) for (const [name, data] of staged.images) next.images[name] = data;
|
|
1462
1493
|
if (this.retiredSessions.has(next.session))
|
|
1463
1494
|
throw new Error(
|
|
1464
1495
|
'The runtime session was replaced; its delayed frame cannot overwrite the active model',
|
|
@@ -1503,6 +1534,12 @@ export class BlenderRuntimeView {
|
|
|
1503
1534
|
}
|
|
1504
1535
|
for (const [id, data] of Object.entries(next.meshes)) {
|
|
1505
1536
|
if ('unchanged' in data) {
|
|
1537
|
+
const incoming = staged?.meshes.get(id);
|
|
1538
|
+
if (incoming) {
|
|
1539
|
+
if (incoming.signature !== `revision:${data.revision}`) throw new Error('Staged Blender mesh revision mismatch');
|
|
1540
|
+
prepared.set(id, incoming);
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1506
1543
|
// A REFERENCE, not geometry: the worker says this store has not been
|
|
1507
1544
|
// written since the frame it last sent, so the resident
|
|
1508
1545
|
// BufferGeometry stands. If it is not resident the worker's record
|
|
@@ -1567,6 +1604,10 @@ export class BlenderRuntimeView {
|
|
|
1567
1604
|
}
|
|
1568
1605
|
throw error;
|
|
1569
1606
|
}
|
|
1607
|
+
if (staged) {
|
|
1608
|
+
for (const [id, value] of staged.meshes) if (!prepared.has(id)) value.geometry.dispose();
|
|
1609
|
+
this.staged = null;
|
|
1610
|
+
}
|
|
1570
1611
|
if (this.frame?.session !== next.session) {
|
|
1571
1612
|
if (this.frame) this.retiredSessions.add(this.frame.session);
|
|
1572
1613
|
this.clear();
|
|
@@ -1929,7 +1970,7 @@ export class BlenderRuntimeView {
|
|
|
1929
1970
|
// nested number arrays it arrived as cost ~15x their JSON (the blower's
|
|
1930
1971
|
// last frame held 850 MB of main-thread heap this way, measured
|
|
1931
1972
|
// 2026-09-13). Reuse checks compare the per-mesh signatures kept above.
|
|
1932
|
-
this.frame = { ...next, meshes: {}, volumes: {} };
|
|
1973
|
+
this.frame = { ...next, meshes: {}, volumes: {}, images: {} };
|
|
1933
1974
|
this.worldKey = JSON.stringify(next.world ?? null);
|
|
1934
1975
|
// THE OVERLAYS, after the graph stands: the weight drawing is laid over
|
|
1935
1976
|
// the presented mesh's own geometry and the bones over the armature
|
|
@@ -2182,6 +2223,8 @@ export class BlenderRuntimeView {
|
|
|
2182
2223
|
}
|
|
2183
2224
|
|
|
2184
2225
|
private clear() {
|
|
2226
|
+
for (const entry of this.staged?.meshes.values() ?? []) entry.geometry.dispose();
|
|
2227
|
+
this.staged = null;
|
|
2185
2228
|
for (const light of this.lights.values()) {
|
|
2186
2229
|
light.removeFromParent();
|
|
2187
2230
|
light.dispose();
|
package/browser/worker.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sendFrameValue } from './frame-stream.mts';
|
|
1
2
|
/**
|
|
2
3
|
* THE BLENDER IN THE TAB IS BLENDER (ARCHITECTURE-CORE, owner ruling
|
|
3
4
|
* 2026-09-17): the editor's modeling engine is Blender 5.2 LTS compiled to
|
|
@@ -39,7 +40,6 @@
|
|
|
39
40
|
|
|
40
41
|
import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
|
|
41
42
|
import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
|
|
42
|
-
import { frameTransferBuffers } from './frame-transfer.mts';
|
|
43
43
|
import { documentChunks } from './document-chunks.mts';
|
|
44
44
|
import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
|
|
45
45
|
|
|
@@ -68,33 +68,20 @@ const pendingPresents = new Map<
|
|
|
68
68
|
{ resolve: (value: PresentAnswer) => void; reject: (error: Error) => void }
|
|
69
69
|
>();
|
|
70
70
|
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
op: 'present',
|
|
86
|
-
id,
|
|
87
|
-
frame,
|
|
88
|
-
description,
|
|
89
|
-
...(capture ? { capture } : {}),
|
|
90
|
-
} satisfies WorkerReply,
|
|
91
|
-
transfer,
|
|
92
|
-
);
|
|
93
|
-
} catch (error) {
|
|
94
|
-
pendingPresents.delete(id);
|
|
95
|
-
reject(error);
|
|
96
|
-
}
|
|
97
|
-
});
|
|
71
|
+
async function streamToTab(value: unknown): Promise<PresentAnswer> {
|
|
72
|
+
return await sendFrameValue(value, chunk => {
|
|
73
|
+
const id = ++presentId;
|
|
74
|
+
return new Promise<PresentAnswer>((resolve, reject) => {
|
|
75
|
+
pendingPresents.set(id, { resolve, reject });
|
|
76
|
+
try {
|
|
77
|
+
(self as unknown as Worker).postMessage({ op: 'frame-stream', id, chunk } satisfies WorkerReply,
|
|
78
|
+
chunk.kind === 'bytes' ? [chunk.bytes.buffer as ArrayBuffer] : []);
|
|
79
|
+
} catch (error) { pendingPresents.delete(id); reject(error); }
|
|
80
|
+
});
|
|
81
|
+
}) as PresentAnswer;
|
|
82
|
+
}
|
|
83
|
+
function presentToTab(frame: unknown, description: unknown, capture?: CaptureRequest): Promise<PresentAnswer> {
|
|
84
|
+
return streamToTab({ op: 'present', frame, description, ...(capture ? { capture } : {}) });
|
|
98
85
|
}
|
|
99
86
|
|
|
100
87
|
let engine: BlenderEngine | null = null;
|
|
@@ -122,6 +109,8 @@ interface Copied {
|
|
|
122
109
|
description: unknown;
|
|
123
110
|
}
|
|
124
111
|
interface PendingFrame {
|
|
112
|
+
session: string;
|
|
113
|
+
revision: number;
|
|
125
114
|
columns: Map<number, Copied>;
|
|
126
115
|
meshes: Map<string, Copied>;
|
|
127
116
|
images: Map<string, Copied>;
|
|
@@ -166,7 +155,10 @@ function assemblePending(held: PendingFrame, frame: unknown): { typed: unknown;
|
|
|
166
155
|
return piece[side];
|
|
167
156
|
}
|
|
168
157
|
const out: Record<string, unknown> = {};
|
|
169
|
-
for (const [name, entry] of Object.entries(value))
|
|
158
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
159
|
+
const item = walk(entry, [...path, name]);
|
|
160
|
+
if (item !== undefined) out[name] = item;
|
|
161
|
+
}
|
|
170
162
|
return out;
|
|
171
163
|
};
|
|
172
164
|
return walk(frame, []);
|
|
@@ -285,7 +277,9 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
285
277
|
// copied out of the heap as they come, because the next export call frees them; the tab
|
|
286
278
|
// gets the whole frame at `present`.
|
|
287
279
|
if (hold !== undefined) {
|
|
288
|
-
|
|
280
|
+
const identity = hold as { session: string; revision: number };
|
|
281
|
+
pending = { session: identity.session, revision: identity.revision, columns: await copyColumns(arena, hold), meshes: new Map(), images: new Map() };
|
|
282
|
+
await streamToTab({ op: 'stage', session: identity.session, revision: identity.revision });
|
|
289
283
|
return {};
|
|
290
284
|
}
|
|
291
285
|
if (mesh !== undefined || image !== undefined) {
|
|
@@ -310,8 +304,14 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
310
304
|
description: await describeFrame(arena, piece),
|
|
311
305
|
};
|
|
312
306
|
}
|
|
313
|
-
|
|
314
|
-
|
|
307
|
+
await streamToTab({ op: 'stage', session: pending.session, revision: pending.revision,
|
|
308
|
+
...(mesh !== undefined ? { mesh } : { image }), piece: copied.typed });
|
|
309
|
+
// Keep only the manifest and digest; the presenter already built this
|
|
310
|
+
// resource, and the next pull may overwrite the engine arena.
|
|
311
|
+
if (mesh !== undefined) {
|
|
312
|
+
const data = copied.typed as { revision: number };
|
|
313
|
+
pending.meshes.set(mesh, { typed: { revision: data.revision, unchanged: true }, description: copied.description });
|
|
314
|
+
} else pending.images.set(image as string, { typed: undefined, description: copied.description });
|
|
315
315
|
return {};
|
|
316
316
|
}
|
|
317
317
|
let typed: unknown;
|