@volter/blender-engine 0.1.149 → 0.1.150
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/LICENSE +6 -5
- package/browser/blender-emscripten-engine.mts +6 -1
- package/browser/blender-wali-engine.mts +2 -0
- package/browser/index.ts +1 -0
- package/browser/protocol.ts +15 -12
- package/browser/runtime.ts +87 -37
- package/browser/session.py +60 -28
- package/browser/three/blender-runtime-view.ts +1 -37
- package/browser/worker.ts +64 -54
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -34,14 +34,15 @@ this package covered by any commercial licence Volter AI, Inc. offers
|
|
|
34
34
|
Blender's authors'.
|
|
35
35
|
|
|
36
36
|
CORRESPONDING SOURCE. The binary in `wasm/` is built from official Blender
|
|
37
|
-
v5.2.0 (`fbe6228777e7d9afefcd61a413844e790ae75db7`) plus
|
|
38
|
-
|
|
39
|
-
`94d8f20b225d51755a0a93a7fdf1e0cd9f53d09c` in:
|
|
37
|
+
v5.2.0 (`fbe6228777e7d9afefcd61a413844e790ae75db7`) plus Volter's changes, at
|
|
38
|
+
the public source commit `241d224a32033b16b383d5065c6fbc819fcdc1d5` in:
|
|
40
39
|
|
|
41
40
|
https://github.com/volter-ai/blender
|
|
42
41
|
|
|
43
|
-
The public release `blender-5.2.0-wasm.
|
|
44
|
-
instructions, dependency source archives
|
|
42
|
+
The public release `blender-5.2.0-wasm.5` retains the matching source archive,
|
|
43
|
+
build instructions, notices and bundle; the dependency source archives the
|
|
44
|
+
source builds with are unchanged since `blender-5.2.0-wasm.1`, which retains
|
|
45
|
+
them. Both stay available for as long as this package is distributed.
|
|
45
46
|
`wasm/BUNDLE.json` records the source pin and artifact hashes;
|
|
46
47
|
`wasm/DEPENDENCY-LICENSES.txt` retains dependency notices. Blender's authorship
|
|
47
48
|
and the GPL terms below are unchanged.
|
|
@@ -218,9 +218,12 @@ export async function startEmscriptenBlenderEngine(
|
|
|
218
218
|
let bootError: unknown = null;
|
|
219
219
|
const started = performance.now();
|
|
220
220
|
let readyLine: string | null = null;
|
|
221
|
+
/** The steps of the session's start it has said it began (`@@VOLTER-PHASE`), not yet made load boundaries. */
|
|
222
|
+
const phases: string[] = [];
|
|
221
223
|
// Route Blender’s stdout and stderr to page output; session conditions use their own reporting channel.
|
|
222
224
|
const say = (_level: 'log' | 'error', text: string) => {
|
|
223
225
|
if (text.startsWith('@@VOLTER-READY ')) readyLine = text.slice('@@VOLTER-READY '.length);
|
|
226
|
+
if (text.startsWith('@@VOLTER-PHASE ')) { phases.push(text.slice('@@VOLTER-PHASE '.length)); return; }
|
|
224
227
|
options.log(sessionLevel(text), text);
|
|
225
228
|
};
|
|
226
229
|
// Observe the factory’s WebAssembly.Memory and read a fresh buffer view after pthread heap growth. Module.HEAPU8 can retain its previous length until the glue refreshes it.
|
|
@@ -305,7 +308,9 @@ export async function startEmscriptenBlenderEngine(
|
|
|
305
308
|
// long before the session exists. The ready line is what says it does.
|
|
306
309
|
while (readyLine === null) {
|
|
307
310
|
if (bootError) throw bootError instanceof Error ? bootError : new Error(String(bootError));
|
|
308
|
-
|
|
311
|
+
const phase = phases.shift();
|
|
312
|
+
if (phase !== undefined) await checkpoint(`session/${phase}`);
|
|
313
|
+
else await sleep(5);
|
|
309
314
|
}
|
|
310
315
|
const bootMs = performance.now() - started;
|
|
311
316
|
const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
|
|
@@ -449,6 +449,8 @@ export async function startWaliBlenderEngine(
|
|
|
449
449
|
for (const line of text.split('\n')) {
|
|
450
450
|
if (line === '') continue;
|
|
451
451
|
if (line.startsWith('@@VOLTER-READY ')) readyLine = line.slice('@@VOLTER-READY '.length);
|
|
452
|
+
// (a start step's mark is a load boundary for a staged load, which this start is not)
|
|
453
|
+
if (line.startsWith('@@VOLTER-PHASE ')) continue;
|
|
452
454
|
// Route Blender streams to page output; named session conditions use their own channel.
|
|
453
455
|
options.log(sessionLevel(line), line);
|
|
454
456
|
}
|
package/browser/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { CaptureRequest, FileEntry, RuntimeStart, NativePreviewRequest, NativePreviewResult, NativeDocumentInfo, FileListSnapshot } from './protocol';
|
|
2
2
|
export {
|
|
3
3
|
BlenderRuntime,
|
|
4
|
+
DocumentNotOpened,
|
|
4
5
|
type BlenderRuntimeOptions,
|
|
5
6
|
type PresentAnswer,
|
|
6
7
|
type SavedDocument,
|
package/browser/protocol.ts
CHANGED
|
@@ -12,16 +12,15 @@ export type WorkerRequest =
|
|
|
12
12
|
/** The document's bytes as a save would write them, for a backup: nothing is carried to the
|
|
13
13
|
* project and the document stays dirty. */
|
|
14
14
|
| { id: number; op: 'snapshot-document' }
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
/** Open `document` in the running Blender, as Blender's own File > Open does, from the project's file, or as
|
|
16
|
+
* `bytes` (a backup), which is then its unsaved state. `document` is the session's `.blend` from then on,
|
|
17
|
+
* PROJECT-RELATIVE (`models/model.blend`): an existing one is opened, an absent one opens as Blender's empty file and
|
|
18
|
+
* is created by the first save. Naming the document the session holds reopens it (a revert, a restore). */
|
|
19
|
+
| { id: number; op: 'open-document'; document: string; bytes?: Uint8Array }
|
|
19
20
|
| { id: number; op: 'history-begin' | 'history-end' }
|
|
20
21
|
| { id: number; op: 'history-step'; token: string; direction: 'undo' | 'redo' }
|
|
21
|
-
/** `
|
|
22
|
-
|
|
23
|
-
* empty and is created by the first save. */
|
|
24
|
-
| { id: number; op: 'start'; project: string; document?: string }
|
|
22
|
+
/** Blender started at `project`, holding no document until `open-document` names one. */
|
|
23
|
+
| { id: number; op: 'start'; project: string }
|
|
25
24
|
| { id: number; op: 'execute'; code: string; history?: boolean; label?: string }
|
|
26
25
|
/** A native Cycles photograph, isolated from authoring/history and the
|
|
27
26
|
* Three frame exporter. This is a preview request, not a modeling edit. */
|
|
@@ -340,9 +339,13 @@ export interface RuntimeStart {
|
|
|
340
339
|
python: string;
|
|
341
340
|
/** The engine implementation that answered: standalone Emscripten or WALI on browser-substrate. */
|
|
342
341
|
skew: 'emscripten' | 'wali';
|
|
343
|
-
|
|
344
|
-
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** What `open-document` answers (`session.py::bind_document`). */
|
|
345
|
+
export interface DocumentOpened {
|
|
346
|
+
/** The session's document, project-relative. */
|
|
347
|
+
document: string;
|
|
345
348
|
/** True when the project already held that `.blend` and it was OPENED —
|
|
346
|
-
* false when
|
|
347
|
-
opened
|
|
349
|
+
* false when it opened as Blender's empty file and is created by the first save. */
|
|
350
|
+
opened: boolean;
|
|
348
351
|
}
|
package/browser/runtime.ts
CHANGED
|
@@ -5,10 +5,12 @@ import { HEAP_GROWTH_REFUSED } from './protocol';
|
|
|
5
5
|
/**
|
|
6
6
|
* The tab-side handle on a Blender session: spawns the worker, forwards wire
|
|
7
7
|
* requests, and hands every presented frame to whoever displays it.
|
|
8
|
-
* One instance per editor tab; the
|
|
8
|
+
* One instance per editor tab; the documents it opens one after another share
|
|
9
|
+
* its Blender, and the session and its model die with the worker.
|
|
9
10
|
*/
|
|
10
11
|
import type {
|
|
11
12
|
CaptureRequest,
|
|
13
|
+
DocumentOpened,
|
|
12
14
|
RuntimeStart,
|
|
13
15
|
WorkerReply,
|
|
14
16
|
WorkerRequest,
|
|
@@ -39,6 +41,15 @@ type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : n
|
|
|
39
41
|
type Request = DistributiveOmit<WorkerRequest, 'id'>;
|
|
40
42
|
|
|
41
43
|
|
|
44
|
+
/** An open that failed before Blender's own open (the path, the file's staging): Blender is as it was, holding the
|
|
45
|
+
* document it held. */
|
|
46
|
+
export class DocumentNotOpened extends Error {}
|
|
47
|
+
|
|
48
|
+
/** Calls a Blender giving up its document still takes ({@link BlenderRuntime.releaseDocument}): a backup's read of
|
|
49
|
+
* the document it saves, and an asset library in its preferences, which no file holds. Each runs in its turn,
|
|
50
|
+
* before the next document's open. */
|
|
51
|
+
const RELEASE_ADMITS: ReadonlySet<string> = new Set(['snapshot-document', 'asset-library']);
|
|
52
|
+
|
|
42
53
|
/** A script every save of a document runs in the save's own turn (`BlenderRuntime.saveDocument`), as the caller names
|
|
43
54
|
* it for the document saved: null for none. */
|
|
44
55
|
export type SaveScript = (document: string) => Promise<{ readonly source: string; readonly args: readonly string[] }> | null;
|
|
@@ -178,9 +189,12 @@ export class BlenderRuntime {
|
|
|
178
189
|
/** The script runs this runtime has made, each in a directory of its own. */
|
|
179
190
|
#scriptRuns = 0;
|
|
180
191
|
#terminated = false;
|
|
192
|
+
/** The document being given up for the next one ({@link releaseDocument}), and its last save. */
|
|
193
|
+
#release: { readonly document: string; readonly next: string; readonly saved: Promise<SavedDocument | null> } | null = null;
|
|
181
194
|
#dirty = false;
|
|
182
195
|
readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
|
|
183
|
-
|
|
196
|
+
// (a Blender holding no document has nothing to lose: its start is not work in progress)
|
|
197
|
+
if (this.#document === null || (!this.#dirty && this.#acceptedRequests === 0)) return;
|
|
184
198
|
event.preventDefault();
|
|
185
199
|
event.returnValue = '';
|
|
186
200
|
};
|
|
@@ -247,9 +261,10 @@ export class BlenderRuntime {
|
|
|
247
261
|
return this.#project;
|
|
248
262
|
}
|
|
249
263
|
|
|
250
|
-
/** The document owned by this worker,
|
|
251
|
-
* a script's Save As leaves Blender holding another
|
|
252
|
-
* it there (`session.py::follow_open_file`).
|
|
264
|
+
/** The document owned by this worker, from the moment its open is asked for: the one
|
|
265
|
+
* {@link openDocument} last named, until a script's Save As leaves Blender holding another
|
|
266
|
+
* project `.blend` and the session follows it there (`session.py::follow_open_file`). Null
|
|
267
|
+
* while Blender holds none. */
|
|
253
268
|
get document(): string | null {
|
|
254
269
|
return this.#document;
|
|
255
270
|
}
|
|
@@ -260,30 +275,15 @@ export class BlenderRuntime {
|
|
|
260
275
|
return [...this.#lastLines];
|
|
261
276
|
}
|
|
262
277
|
|
|
263
|
-
/** Idempotent: the first call boots Blender at `project`'s
|
|
264
|
-
*
|
|
265
|
-
|
|
266
|
-
* `document` is the session's `.blend`, project-relative. An explicit
|
|
267
|
-
* different file is a resource conflict, including while boot is pending.
|
|
268
|
-
* Omitting it on an already-started handle means await that same session. */
|
|
269
|
-
start(project: string, document?: string): Promise<RuntimeStart> {
|
|
278
|
+
/** Idempotent: the first call boots Blender at `project`'s absolute path, holding no
|
|
279
|
+
* document; later calls await it (a different path is refused). */
|
|
280
|
+
start(project: string): Promise<RuntimeStart> {
|
|
270
281
|
if (this.#project !== null && this.#project !== project)
|
|
271
282
|
return Promise.reject(
|
|
272
283
|
new Error(`The Blender session is bound to ${this.#project}, not ${project}`),
|
|
273
284
|
);
|
|
274
|
-
if (document !== undefined && this.#document !== null && this.#document !== document)
|
|
275
|
-
return Promise.reject(
|
|
276
|
-
new Error(
|
|
277
|
-
`Blender resource conflict: the session holds ${this.#document}; cannot open ${document}`,
|
|
278
|
-
),
|
|
279
|
-
);
|
|
280
285
|
this.#project = project;
|
|
281
|
-
this.#
|
|
282
|
-
this.#started ??= (this.#request({
|
|
283
|
-
op: 'start',
|
|
284
|
-
project,
|
|
285
|
-
...(document === undefined ? {} : { document }),
|
|
286
|
-
}) as Promise<RuntimeStart>).catch((error: unknown) => {
|
|
286
|
+
this.#started ??= (this.#request({ op: 'start', project }) as Promise<RuntimeStart>).catch((error: unknown) => {
|
|
287
287
|
this.#startFailed = true;
|
|
288
288
|
throw error;
|
|
289
289
|
});
|
|
@@ -510,9 +510,11 @@ export class BlenderRuntime {
|
|
|
510
510
|
/** A save of the document, and in the same turn, before any other request reaches this Blender, the script `after`
|
|
511
511
|
* names for the document it wrote (as the worker holds it once saved, a move accepted before it included), run on
|
|
512
512
|
* the scene just saved, in a working directory of its own, its files carried back in its answer. Null when there
|
|
513
|
-
* was nothing to save.
|
|
513
|
+
* was nothing to save. While the document is being given up, the save is the release's own: this resolves when it
|
|
514
|
+
* lands, with null (its listeners hear it once, from the release), and fails with it. */
|
|
514
515
|
async saveDocument(after?: SaveScript): Promise<SavedDocument | null> {
|
|
515
516
|
await this.#ready();
|
|
517
|
+
if (this.#release !== null) return this.#release.saved.then(() => null);
|
|
516
518
|
return this.#inTurn('flush-document', false, () => this.#save(after));
|
|
517
519
|
}
|
|
518
520
|
|
|
@@ -522,16 +524,56 @@ export class BlenderRuntime {
|
|
|
522
524
|
return (await this.#request({ op: 'snapshot-document' })) as Uint8Array;
|
|
523
525
|
}
|
|
524
526
|
|
|
525
|
-
/**
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
527
|
+
/**
|
|
528
|
+
* Open `document` (project-relative) in this Blender, once it has started, as its File > Open does: from
|
|
529
|
+
* the project's file, or as `bytes`, its unsaved state (a backup). Naming the document it holds reopens
|
|
530
|
+
* it, discarding its changes (a revert, a restore). The handle claims the document at the call, so a
|
|
531
|
+
* request made after it waits for the open and finds it; the one held before is the caller's to save
|
|
532
|
+
* first ({@link releaseDocument}). A failure before Blender's own open rejects with {@link DocumentNotOpened}, and
|
|
533
|
+
* the handle names the document it held again.
|
|
534
|
+
*/
|
|
535
|
+
openDocument(document: string, bytes?: Uint8Array): Promise<DocumentOpened> {
|
|
536
|
+
const held = this.#document;
|
|
537
|
+
this.#document = document;
|
|
538
|
+
const request = { op: 'open-document', document, ...(bytes === undefined ? {} : { bytes }) } as const;
|
|
539
|
+
return this.#ready().then(() => this.#inTurn('open-document', false, async () => {
|
|
540
|
+
// The open the release was for: the document given up is no longer held.
|
|
541
|
+
if (this.#release?.next === document) this.#release = null;
|
|
542
|
+
// From this turn on, a frame presented is the opened file's: the last one's is no longer on screen as its.
|
|
543
|
+
this.#presented = null;
|
|
544
|
+
const answer = (await this.#wire(request)) as DocumentOpened | { readonly notOpened: string };
|
|
545
|
+
if (!('notOpened' in answer)) return answer;
|
|
546
|
+
if (this.#document === document) this.#document = held;
|
|
547
|
+
throw new DocumentNotOpened(answer.notOpened);
|
|
548
|
+
}, document));
|
|
529
549
|
}
|
|
530
550
|
|
|
531
|
-
/**
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
551
|
+
/**
|
|
552
|
+
* THE DOCUMENT GIVEN UP FOR `next`, ITS LAST SAVE FIRST, as a stop's is: queued behind every request accepted before
|
|
553
|
+
* it, with `retire.after`'s script in its turn, heard by `landed`. From the call on, no edit is taken, so none lands
|
|
554
|
+
* after the save, until `next`'s {@link openDocument} has its turn or {@link keepDocument} returns to the document;
|
|
555
|
+
* a save asked for meanwhile is this one, and a backup's read and an asset library still run in their turns
|
|
556
|
+
* (`RELEASE_ADMITS`). A save that fails, or a listener that throws, keeps the document, and edits are taken again.
|
|
557
|
+
*/
|
|
558
|
+
releaseDocument(next: string, retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
|
|
559
|
+
if (this.#terminated || this.#stopping) return Promise.reject(new Error('The Blender session is stopping or terminated'));
|
|
560
|
+
const document = this.#document;
|
|
561
|
+
if (document === null) return Promise.resolve();
|
|
562
|
+
const saved = this.#ready().then(() => this.#inTurn('flush-document', true, () => this.#save(retire?.after)));
|
|
563
|
+
const release = { document, next, saved };
|
|
564
|
+
this.#release = release;
|
|
565
|
+
return saved
|
|
566
|
+
.then((value) => { if (value !== null) retire?.landed(value); })
|
|
567
|
+
.catch((error: unknown) => {
|
|
568
|
+
if (this.#release === release) this.#release = null;
|
|
569
|
+
throw error;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The document given up ({@link releaseDocument}) is kept after all, while the handle still names it: edits are
|
|
574
|
+
* taken again. Once the next document's open is asked for, that open ends the release instead. */
|
|
575
|
+
keepDocument(): void {
|
|
576
|
+
if (this.#release !== null && this.#release.document === this.#document) this.#release = null;
|
|
535
577
|
}
|
|
536
578
|
|
|
537
579
|
async readFile(path: string): Promise<Uint8Array> {
|
|
@@ -563,6 +605,11 @@ export class BlenderRuntime {
|
|
|
563
605
|
stop(retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
|
|
564
606
|
if (this.#terminated) return Promise.resolve();
|
|
565
607
|
if (this.#stopping) return this.#stopping;
|
|
608
|
+
// Holding no document, it has nothing to save, and its start is not waited for.
|
|
609
|
+
if (this.#document === null) {
|
|
610
|
+
this.terminate();
|
|
611
|
+
return Promise.resolve();
|
|
612
|
+
}
|
|
566
613
|
this.#stopping = (async () => {
|
|
567
614
|
// A start that failed opened no document, so there is nothing to flush: its stale
|
|
568
615
|
// rejection must not refuse every later close.
|
|
@@ -580,9 +627,9 @@ export class BlenderRuntime {
|
|
|
580
627
|
|
|
581
628
|
/** The save, and its script on the scene it wrote, inside one turn. */
|
|
582
629
|
async #save(after: SaveScript | undefined): Promise<SavedDocument | null> {
|
|
583
|
-
|
|
584
|
-
const document = this.#document;
|
|
585
|
-
if (!saved || document ===
|
|
630
|
+
// The document the worker wrote, as it says: this handle may already name the next one.
|
|
631
|
+
const { saved, document } = (await this.#wire({ op: 'flush-document' })) as { saved: boolean; document?: string };
|
|
632
|
+
if (!saved || document === undefined) return null;
|
|
586
633
|
const named = after?.(document) ?? null;
|
|
587
634
|
if (named === null) return { document, script: null };
|
|
588
635
|
let script: { readonly source: string; readonly args: readonly string[] };
|
|
@@ -696,9 +743,12 @@ export class BlenderRuntime {
|
|
|
696
743
|
return this.#inTurn(request.op, false, () => this.#wire(request));
|
|
697
744
|
}
|
|
698
745
|
/** One turn of the queue: `run` alone talks to the worker until it is done, whatever it sends. */
|
|
699
|
-
#inTurn<T>(op: string, shutdown: boolean, run: () => Promise<T
|
|
746
|
+
#inTurn<T>(op: string, shutdown: boolean, run: () => Promise<T>, opens?: string): Promise<T> {
|
|
700
747
|
if (this.#terminated || (this.#stopping && !shutdown))
|
|
701
748
|
return Promise.reject(new Error('The Blender session is stopping or terminated'));
|
|
749
|
+
const release = this.#release;
|
|
750
|
+
if (release !== null && !shutdown && !RELEASE_ADMITS.has(op) && opens !== release.next)
|
|
751
|
+
return Promise.reject(new Error(`Blender is saving ${release.document} before opening ${release.next}, and takes no edit until ${release.next} is open.`));
|
|
702
752
|
const began = performance.now();
|
|
703
753
|
let endWork = () => {};
|
|
704
754
|
this.#acceptedRequests++;
|
package/browser/session.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"""The tab’s long-lived Blender session. A directory protocol carries requests and replies while one bpy.data remains alive on Blender’s pthread. The page owns in/ and reply/; the guest owns out/ and ask/. Each writer cleans its own files after acknowledgement and removes .done last. Explicit saves write Blender’s native format and let worker.ts return changed output to the project."""
|
|
2
2
|
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
# The session module begins loading (a start step: see `main`).
|
|
6
|
+
print("@@VOLTER-PHASE module", file=sys.stderr, flush=True)
|
|
7
|
+
|
|
3
8
|
import array
|
|
4
9
|
import base64
|
|
5
10
|
import hashlib
|
|
@@ -8,7 +13,6 @@ import json
|
|
|
8
13
|
import math
|
|
9
14
|
import os
|
|
10
15
|
import re
|
|
11
|
-
import sys
|
|
12
16
|
import time
|
|
13
17
|
import traceback
|
|
14
18
|
from contextlib import redirect_stdout
|
|
@@ -107,6 +111,7 @@ def _load_post(_arg):
|
|
|
107
111
|
# The DOOR resets its own revision table on LOAD_POST (it holds ID pointers
|
|
108
112
|
# into the Main that just went away); this is the session's half.
|
|
109
113
|
SESSION.forget()
|
|
114
|
+
SESSION.renew()
|
|
110
115
|
SESSION.loaded()
|
|
111
116
|
# Factory reset reloads add-ons. Preserve native Cycles and re-register
|
|
112
117
|
# only the headless GPU presenters we own.
|
|
@@ -1421,10 +1426,10 @@ class Session:
|
|
|
1421
1426
|
self._known = {}
|
|
1422
1427
|
# The last frame's accounting (`_present`), read by `dispatch`.
|
|
1423
1428
|
self.last_shipped = None
|
|
1424
|
-
# THE SESSION'S DOCUMENT: the `.blend` it
|
|
1429
|
+
# THE SESSION'S DOCUMENT: the `.blend` it last opened and saves back
|
|
1425
1430
|
# into. Absolute (the module filesystem mirrors the project at its own
|
|
1426
1431
|
# host path) plus the project-relative spelling the tab needs to name
|
|
1427
|
-
# the destination. None until `
|
|
1432
|
+
# the destination. None until `open-document` names one.
|
|
1428
1433
|
self.document = None
|
|
1429
1434
|
self.document_relative = None
|
|
1430
1435
|
# Set by a present that left the document behind the model; cleared by
|
|
@@ -1452,6 +1457,15 @@ class Session:
|
|
|
1452
1457
|
self._known.clear()
|
|
1453
1458
|
self._action_revisions = {}
|
|
1454
1459
|
|
|
1460
|
+
def renew(self):
|
|
1461
|
+
"""A NEW PRESENTATION SESSION FOR EACH FILE LOAD: the editor's open of a document, a
|
|
1462
|
+
script's `open_mainfile` or `read_homefile` alike. A load restarts the revisions the export
|
|
1463
|
+
door numbers its datablocks with, and the view keeps a mesh whose session and revision it
|
|
1464
|
+
already holds (`blender-runtime-view.ts`), so another file's "Cube" at the same revision
|
|
1465
|
+
would be drawn as the last one's. A new id makes the view clear what it held and refuse the
|
|
1466
|
+
last file's late frames."""
|
|
1467
|
+
self.session = "blender-%d" % time.time_ns()
|
|
1468
|
+
|
|
1455
1469
|
def _kept_action_revisions(self, door):
|
|
1456
1470
|
"""Keep action revisions stable across evaluation-only updates. An edit advances
|
|
1457
1471
|
_ACTION_EDITS and permits the native revision to replace the retained revision. All
|
|
@@ -1905,23 +1919,33 @@ class Session:
|
|
|
1905
1919
|
def bind_document(self, relative_path):
|
|
1906
1920
|
"""Name the session's document and OPEN it when the project has one.
|
|
1907
1921
|
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
`open_mainfile`) to another
|
|
1911
|
-
`bpy.data.filepath`, and
|
|
1912
|
-
after the call.
|
|
1922
|
+
The session holds one document at a time: the one the editor last opened
|
|
1923
|
+
here (`open-document`), and it moves otherwise only where Blender itself
|
|
1924
|
+
moved: a script's own `save_as_mainfile` (or `open_mainfile`) to another
|
|
1925
|
+
`.blend` in the project retargets `bpy.data.filepath`, and
|
|
1926
|
+
`follow_open_file` moves the document with it after the call.
|
|
1913
1927
|
|
|
1914
1928
|
`open_mainfile` runs the session's `_load_post` like any other load
|
|
1915
1929
|
(the export door's revision table is reset, the render engine ids are
|
|
1916
1930
|
retaken), and the one present afterwards is what puts the reopened
|
|
1917
|
-
model on the tab's screen.
|
|
1931
|
+
model on the tab's screen, as a new presentation session (`renew`).
|
|
1918
1932
|
"""
|
|
1919
1933
|
self.document_relative = relative_path
|
|
1920
1934
|
self.document = os.path.join(self.project, relative_path)
|
|
1921
1935
|
self._foreign = False
|
|
1922
1936
|
self._foreign_said = False
|
|
1937
|
+
self._outside_file = None
|
|
1923
1938
|
if not os.path.exists(self.document):
|
|
1924
|
-
|
|
1939
|
+
# A DOCUMENT NOT YET WRITTEN OPENS EMPTY: Blender's empty file
|
|
1940
|
+
# (`read_homefile(use_empty=True)`: its scenes, windows and workspaces alone), loaded as the session's
|
|
1941
|
+
# own open, so its first save writes the new model, never the factory startup's cube, light and camera
|
|
1942
|
+
# a fresh worker holds, or the file it held before.
|
|
1943
|
+
self._binding = True
|
|
1944
|
+
try:
|
|
1945
|
+
bpy.ops.wm.read_homefile(use_empty=True)
|
|
1946
|
+
finally:
|
|
1947
|
+
self._binding = False
|
|
1948
|
+
return {"document": relative_path, "opened": False, "presented": self._present_opened(relative_path)}
|
|
1925
1949
|
_asked({"checkpoint": "document-open"})
|
|
1926
1950
|
_mark("open:before")
|
|
1927
1951
|
_blender_web.set_read_checkpoint(lambda: _asked({"checkpoint": "file-read"}))
|
|
@@ -1941,10 +1965,19 @@ class Session:
|
|
|
1941
1965
|
# Remove the staged .blend after Blender loads it to release its WasmFS bytes before
|
|
1942
1966
|
# presentation. The worker keeps its host stamp, and the next save rewrites the file.
|
|
1943
1967
|
os.remove(self.document)
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1968
|
+
presented = self._present_opened(relative_path)
|
|
1969
|
+
return {"document": relative_path, "opened": True,
|
|
1970
|
+
"objects": len(bpy.data.objects),
|
|
1971
|
+
"size": size, "presented": presented}
|
|
1972
|
+
|
|
1973
|
+
def _present_opened(self, relative_path):
|
|
1974
|
+
"""The first present of a document just opened, and whether it was taken.
|
|
1975
|
+
|
|
1976
|
+
A DOCUMENT BLENDER OPENED IS OPEN, whatever its first present does: a present that
|
|
1977
|
+
failed the open would leave a saved document unopenable and every MCP call refused.
|
|
1978
|
+
The model is in Blender, so the session keeps it and says loudly that the view is behind;
|
|
1979
|
+
the next edit presents again, and can remove whatever the presenter could not take.
|
|
1980
|
+
"""
|
|
1948
1981
|
presented = True
|
|
1949
1982
|
try:
|
|
1950
1983
|
self.present()
|
|
@@ -1959,9 +1992,7 @@ class Session:
|
|
|
1959
1992
|
"document does not show it yet: %r" % (relative_path, thrown))
|
|
1960
1993
|
# The load did not dirty anything: what is in memory IS the file.
|
|
1961
1994
|
self.save_due = False
|
|
1962
|
-
return
|
|
1963
|
-
"objects": len(bpy.data.objects),
|
|
1964
|
-
"size": size, "presented": presented}
|
|
1995
|
+
return presented
|
|
1965
1996
|
|
|
1966
1997
|
def loaded(self):
|
|
1967
1998
|
"""Track file loads that replace the session’s document model. The session’s own document
|
|
@@ -5828,23 +5859,19 @@ def _dispatch(request):
|
|
|
5828
5859
|
return answer
|
|
5829
5860
|
if op == "save-document":
|
|
5830
5861
|
return SESSION.save_document()
|
|
5831
|
-
if op == "
|
|
5832
|
-
# The document's file
|
|
5833
|
-
# backup); open it
|
|
5834
|
-
return SESSION.bind_document(
|
|
5862
|
+
if op == "open-document":
|
|
5863
|
+
# The worker just staged the document's file into the engine's filesystem (another
|
|
5864
|
+
# `.blend`, or this one again for a revert or a backup); open it as the model.
|
|
5865
|
+
return SESSION.bind_document(request["document"])
|
|
5835
5866
|
if op == "start":
|
|
5836
5867
|
SESSION.project = request.get("project", SESSION.project)
|
|
5837
5868
|
try:
|
|
5838
5869
|
os.chdir(SESSION.project)
|
|
5839
5870
|
except OSError:
|
|
5840
5871
|
pass
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
document = request.get("document")
|
|
5845
|
-
if document:
|
|
5846
|
-
answer.update(SESSION.bind_document(document))
|
|
5847
|
-
return answer
|
|
5872
|
+
return {"session": SESSION.session, "python": sys.version,
|
|
5873
|
+
"blender": bpy.app.version_string, "engines": ENGINES,
|
|
5874
|
+
"unavailableEngines": UNAVAILABLE_ENGINES}
|
|
5848
5875
|
raise ValueError("Unknown session op %r" % (op,))
|
|
5849
5876
|
|
|
5850
5877
|
|
|
@@ -5931,6 +5958,7 @@ def _enable_addons():
|
|
|
5931
5958
|
import addon_utils
|
|
5932
5959
|
sys.path.insert(0, _ADDONS)
|
|
5933
5960
|
for name in sorted(os.listdir(_ADDONS)):
|
|
5961
|
+
_say("@@VOLTER-PHASE addon/%s" % name)
|
|
5934
5962
|
failed = []
|
|
5935
5963
|
addon_utils.enable(name, default_set=True, persistent=True, handle_error=failed.append)
|
|
5936
5964
|
if failed:
|
|
@@ -5942,10 +5970,14 @@ def main():
|
|
|
5942
5970
|
global ENGINES, UNAVAILABLE_ENGINES
|
|
5943
5971
|
if _blender_web is None:
|
|
5944
5972
|
raise RuntimeError("The session worker needs Blender with the _blender_web native door")
|
|
5973
|
+
# Each step of the session's start says it began (`@@VOLTER-PHASE`): the page makes each a load boundary, so the
|
|
5974
|
+
# wait for the ready line is timed step by step, Blender's and Python's own start being the wait before the first.
|
|
5975
|
+
_say("@@VOLTER-PHASE session")
|
|
5945
5976
|
_prepare_directories()
|
|
5946
5977
|
bpy.app.handlers.load_post.append(_load_post)
|
|
5947
5978
|
bpy.app.handlers.undo_post.append(_history_post)
|
|
5948
5979
|
bpy.app.handlers.redo_post.append(_history_post)
|
|
5980
|
+
_say("@@VOLTER-PHASE engines")
|
|
5949
5981
|
ENGINES, UNAVAILABLE_ENGINES = _register_engine()
|
|
5950
5982
|
_enable_addons()
|
|
5951
5983
|
_say("@@VOLTER-READY " + json.dumps({"blender": bpy.app.version_string, "engines": ENGINES,
|
|
@@ -1185,16 +1185,7 @@ export class BlenderRuntimeView {
|
|
|
1185
1185
|
}
|
|
1186
1186
|
|
|
1187
1187
|
stageMode(): string | null {
|
|
1188
|
-
return this.
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
/** PLAY IS A MODE OF THE STAGE: while the document plays, the keys are the player's, and the
|
|
1192
|
-
* mode this context publishes is what the frame's key rules stand down on
|
|
1193
|
-
* (`volter.stage.mode != 'PLAY'`). Blender's own mode returns on stop. */
|
|
1194
|
-
private playing = false;
|
|
1195
|
-
|
|
1196
|
-
setPlaying(playing: boolean): void {
|
|
1197
|
-
this.playing = playing;
|
|
1188
|
+
return this.mode;
|
|
1198
1189
|
}
|
|
1199
1190
|
|
|
1200
1191
|
/**
|
|
@@ -2594,13 +2585,6 @@ export class BlenderRuntimeView {
|
|
|
2594
2585
|
return this.frame;
|
|
2595
2586
|
}
|
|
2596
2587
|
|
|
2597
|
-
/** The frame this view holds with no objects in it: its World, render and view settings. */
|
|
2598
|
-
private worldFrame(): Frame {
|
|
2599
|
-
const source = this.frame;
|
|
2600
|
-
if (!source) throw new Error('Blender capture requires a presented frame');
|
|
2601
|
-
return structuredClone({ ...source, objects: [], meshes: {}, materials: {}, instances: [], lights: {}, volumes: {}, images: {}, armatures: {}, weights: null });
|
|
2602
|
-
}
|
|
2603
|
-
|
|
2604
2588
|
/** Everything this view holds, as ONE self-contained frame another view can be built from:
|
|
2605
2589
|
* every mesh's resident geometry and every resident image, with no reference to what a
|
|
2606
2590
|
* receiver already holds. */
|
|
@@ -2717,26 +2701,6 @@ export class BlenderRuntimeView {
|
|
|
2717
2701
|
};
|
|
2718
2702
|
}
|
|
2719
2703
|
|
|
2720
|
-
/**
|
|
2721
|
-
* THE WORLD OF THE MODEL, FOR PLAY: a view of the World and the scene's settings this view holds now, with none of
|
|
2722
|
-
* the model's objects (a game over the standard artifact brings its own, RFC 0007), handed nothing afterwards.
|
|
2723
|
-
* Blender's data and this view never see it, and disposing it is the whole undo.
|
|
2724
|
-
*/
|
|
2725
|
-
detach(): { readonly view: BlenderRuntimeView; dispose(): void } {
|
|
2726
|
-
const detached = new BlenderRuntimeView();
|
|
2727
|
-
if (this.frame) detached.applyFrame(this.worldFrame());
|
|
2728
|
-
let disposed = false;
|
|
2729
|
-
return {
|
|
2730
|
-
view: detached,
|
|
2731
|
-
dispose: () => {
|
|
2732
|
-
if (disposed) return;
|
|
2733
|
-
disposed = true;
|
|
2734
|
-
detached.dispose();
|
|
2735
|
-
detached.root.removeFromParent();
|
|
2736
|
-
},
|
|
2737
|
-
};
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
2704
|
/**
|
|
2741
2705
|
* A revision-owned render presenter, never a clone of the mounted scene.
|
|
2742
2706
|
* Copy the resident draw arrays and image bytes synchronously, then rebuild
|
package/browser/worker.ts
CHANGED
|
@@ -57,9 +57,9 @@ let loadCheckpoint: ((phase: string) => Promise<void>) | null = null;
|
|
|
57
57
|
|
|
58
58
|
// ---- The session's document.
|
|
59
59
|
//
|
|
60
|
-
// One
|
|
61
|
-
//
|
|
62
|
-
// no host path is ever on the wire.
|
|
60
|
+
// One `.blend` at a time, the one `open-document` last named (or a script's Save As moved
|
|
61
|
+
// to). `documentPath` is the PROJECT-RELATIVE spelling — the only one that crosses to the
|
|
62
|
+
// server, which joins it to its own root so no host path is ever on the wire.
|
|
63
63
|
let documentPath: string | null = null;
|
|
64
64
|
let documentDirty = false;
|
|
65
65
|
/** The revision the last reported change was. */
|
|
@@ -256,32 +256,64 @@ async function saveDocument(): Promise<boolean> {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* not fetch the file again once Blender has read and
|
|
262
|
-
* backup is then written over it. Blender presents what it opened.
|
|
259
|
+
* OPEN `document` IN THE RUNNING BLENDER from the project's file, or as `backup` (its unsaved state at a
|
|
260
|
+
* restart): another `.blend`, or the one it holds again (a revert, a restore). The project's copy is staged
|
|
261
|
+
* in fresh, so the stager keeps its host stamp and does not fetch the file again once Blender has read and
|
|
262
|
+
* removed it (`session.py::bind_document`); a backup is then written over it. Blender presents what it opened.
|
|
263
|
+
*
|
|
264
|
+
* A failure before Blender's own open (the path, the file's staging, a backup with no project file) leaves
|
|
265
|
+
* Blender as it was, holding the document it held, and answers `notOpened` with why; one inside Blender's
|
|
266
|
+
* open throws.
|
|
263
267
|
*/
|
|
264
|
-
async function
|
|
265
|
-
if (!engine || !projectRoot
|
|
266
|
-
const path = `${projectRoot}/${documentPath}`;
|
|
267
|
-
staged.delete(path);
|
|
268
|
+
async function openDocument(document: string, backup?: Uint8Array): Promise<unknown> {
|
|
269
|
+
if (!engine || !projectRoot) throw new Error('The Blender session has not started');
|
|
268
270
|
try {
|
|
269
|
-
await engine
|
|
270
|
-
} catch {
|
|
271
|
-
|
|
271
|
+
await stageDocument(engine, projectRoot, document, backup);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return { notOpened: describeThrown(error) };
|
|
272
274
|
}
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
275
|
+
documentPath = document;
|
|
276
|
+
const opened = await engine.request({ op: 'open-document', document });
|
|
277
|
+
setDocumentDirty(backup !== undefined);
|
|
278
|
+
return opened;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** `document`'s file staged into the engine for its open, from the project or as `backup`. */
|
|
282
|
+
async function stageDocument(engine: BlenderEngine, projectRoot: string, document: string, backup?: Uint8Array): Promise<void> {
|
|
283
|
+
// The document is the project's own file and is named the project's own way. An absolute path here
|
|
284
|
+
// would be a host path on the wire and a destination the page chose, which this transport never carries.
|
|
285
|
+
if (!isDocumentPath(document))
|
|
286
|
+
throw new Error(
|
|
287
|
+
`The Blender document must be a project-relative .blend path with no traversal ` +
|
|
288
|
+
`(models/model.blend); got ${JSON.stringify(document)}`,
|
|
289
|
+
);
|
|
290
|
+
// The file given up and the one opened leave the engine and are staged again from the project: the
|
|
291
|
+
// one given up was saved there, and its copy here is the session's no longer (a script reads it, and a
|
|
292
|
+
// listing of the session's own files leaves it out, as it does every project file staged in).
|
|
293
|
+
for (const path of new Set([documentPath, document].flatMap((held) => held === null ? [] : [`${projectRoot}/${held}`]))) {
|
|
294
|
+
staged.delete(path);
|
|
295
|
+
try {
|
|
296
|
+
await engine.files.unlink(path);
|
|
297
|
+
} catch {
|
|
298
|
+
/* not in the engine: never staged, or the open already removed it */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const path = `${projectRoot}/${document}`;
|
|
302
|
+
// The document itself not reaching the engine is a refusal, not a footnote: the open would
|
|
303
|
+
// fail on a missing file (or open a stale copy) without saying why.
|
|
304
|
+
const failed = await stageProjectFiles(engine.files, projectRoot);
|
|
305
|
+
const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
|
|
306
|
+
if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
|
|
307
|
+
await loadCheckpoint?.('project-imported');
|
|
276
308
|
if (backup) {
|
|
309
|
+
const host = staged.get(path)?.host;
|
|
310
|
+
if (host === undefined) throw new Error(`${document} could not be read from the project`);
|
|
277
311
|
await engine.files.writeFile(path, backup);
|
|
278
312
|
staged.set(path, { host, engine: await stampOf(engine.files, path) });
|
|
279
313
|
}
|
|
280
|
-
await engine.request({ op: 'reopen-document' });
|
|
281
|
-
setDocumentDirty(backup !== undefined);
|
|
282
314
|
}
|
|
283
315
|
|
|
284
|
-
async function startBlender(project: string
|
|
316
|
+
async function startBlender(project: string): Promise<unknown> {
|
|
285
317
|
// The engine is named through a holder rather than the module-level
|
|
286
318
|
// `engine`, because `ask` is handed to the engine before the engine exists.
|
|
287
319
|
const holder: { engine: BlenderEngine | null } = { engine: null };
|
|
@@ -372,22 +404,8 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
372
404
|
holder.engine = started;
|
|
373
405
|
await loadCheckpoint?.("engine-ready");
|
|
374
406
|
engine = started;
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
if (document) {
|
|
378
|
-
// The document itself not reaching the engine is a refusal, not a footnote: the open below
|
|
379
|
-
// would fail on a missing file (or open a stale copy) without saying why.
|
|
380
|
-
const failed = await stageProjectFiles(started.files, project);
|
|
381
|
-
const mine = failed.find((line) => line.startsWith("The project's files are not readable") || line.startsWith(`${document} `));
|
|
382
|
-
if (mine) throw new Error(`${document} could not be copied into Blender, so it cannot be opened: ${mine}`);
|
|
383
|
-
}
|
|
384
|
-
await loadCheckpoint?.("project-imported");
|
|
385
|
-
const banner = (await started.request({
|
|
386
|
-
op: 'start',
|
|
387
|
-
project,
|
|
388
|
-
...(document ? { document } : {}),
|
|
389
|
-
})) as Record<string, unknown>;
|
|
390
|
-
if (document) documentPath = document;
|
|
407
|
+
// The project's files are staged by each call that reads them: a document's open, a script's run.
|
|
408
|
+
const banner = (await started.request({ op: 'start', project })) as Record<string, unknown>;
|
|
391
409
|
session = {
|
|
392
410
|
sceneInfo: async () => JSON.stringify(await started.request({ op: 'scene-info' }), null, 2),
|
|
393
411
|
objectInfo: async (name: string) =>
|
|
@@ -428,19 +446,11 @@ export function projectMount(project: string): string {
|
|
|
428
446
|
return /^[A-Za-z]:[\\/]/.test(project) ? `/${project.replaceAll('\\', '/')}` : project;
|
|
429
447
|
}
|
|
430
448
|
|
|
431
|
-
async function start(hostProject: string
|
|
449
|
+
async function start(hostProject: string): Promise<unknown> {
|
|
432
450
|
if (session) throw new Error('The Blender session is already started');
|
|
433
451
|
const project = projectMount(hostProject);
|
|
434
452
|
if (!project.startsWith('/'))
|
|
435
453
|
throw new Error("The Blender session needs the project's absolute path");
|
|
436
|
-
// The document is the project's own file and is named the project's own way.
|
|
437
|
-
// An absolute path here would be a host path on the wire and a destination
|
|
438
|
-
// the page chose, which is the one thing this transport does not carry.
|
|
439
|
-
if (document !== undefined && !isDocumentPath(document))
|
|
440
|
-
throw new Error(
|
|
441
|
-
`The Blender document must be a project-relative .blend path with no traversal ` +
|
|
442
|
-
`(models/model.blend); got ${JSON.stringify(document)}`,
|
|
443
|
-
);
|
|
444
454
|
const served = await blenderIsServed();
|
|
445
455
|
if (!served.available)
|
|
446
456
|
throw new Error(
|
|
@@ -449,7 +459,7 @@ async function start(hostProject: string, document?: string): Promise<unknown> {
|
|
|
449
459
|
'(packages/blender-engine/wasm, or the directory VOLTER_BLENDER_WASM_DIR names); nothing stands in for it.',
|
|
450
460
|
);
|
|
451
461
|
projectRoot = project;
|
|
452
|
-
return startBlender(project
|
|
462
|
+
return startBlender(project);
|
|
453
463
|
}
|
|
454
464
|
|
|
455
465
|
/** A project-relative `.blend`, with no traversal and no absolute root. The
|
|
@@ -663,7 +673,7 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
663
673
|
switch (request.op) {
|
|
664
674
|
case 'start': {
|
|
665
675
|
if (loadJob) throw new Error('The Blender load has already started');
|
|
666
|
-
return pullWork('start', () => start(request.project
|
|
676
|
+
return pullWork('start', () => start(request.project));
|
|
667
677
|
}
|
|
668
678
|
case 'load-next':
|
|
669
679
|
if (!loadJob) throw new Error('No Blender load to continue');
|
|
@@ -691,20 +701,20 @@ async function handle(request: WorkerRequest): Promise<unknown> {
|
|
|
691
701
|
case 'flush-document':
|
|
692
702
|
// A flush lands what is pending; a document nothing changed stays the
|
|
693
703
|
// file it was opened from.
|
|
704
|
+
// (it names what it saved: the tab's handle may already name the next document)
|
|
694
705
|
if (!documentDirty) return { saved: false };
|
|
695
|
-
|
|
706
|
+
{
|
|
707
|
+
const document = documentPath;
|
|
708
|
+
return { saved: await saveDocument(), ...(document === null ? {} : { document }) };
|
|
709
|
+
}
|
|
696
710
|
case 'snapshot-document': {
|
|
697
711
|
if (documentPath === null) throw new Error('The Blender session holds no document');
|
|
698
712
|
const snapshot = await engineDocument(documentPath, () => {});
|
|
699
713
|
if (snapshot === null) throw new Error(`Blender holds another file's model, not ${documentPath}`);
|
|
700
714
|
return snapshot.bytes;
|
|
701
715
|
}
|
|
702
|
-
case '
|
|
703
|
-
|
|
704
|
-
return undefined;
|
|
705
|
-
case 'restore-document':
|
|
706
|
-
await reopenDocument(request.bytes);
|
|
707
|
-
return undefined;
|
|
716
|
+
case 'open-document':
|
|
717
|
+
return pullWork('open-document', () => openDocument(request.document, request.bytes));
|
|
708
718
|
case 'history-begin':
|
|
709
719
|
case 'history-end':
|
|
710
720
|
return ask({ op: request.op });
|