@volter/blender-engine 0.1.6 → 0.1.8

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.
@@ -133,9 +133,37 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
133
133
  };
134
134
  }
135
135
 
136
+ /**
137
+ * BLENDER'S ARTIFACTS ARE FETCHED ONCE PER BUILD. The wasm (86 MB decoded), the
138
+ * `.data` package (53 MB) and the Essentials payload are immutable for a build,
139
+ * and the page keeps them in Cache Storage under the digest the artifact door
140
+ * reports, so a later open reads them at the browser's own speed instead of
141
+ * from the editor's server. Measured in the browser substrate, where that
142
+ * server runs in the tab: the three took 7.2 s per open (4.2 s for the wasm
143
+ * alone), about half of it decoding their brotli. A file the door gives no
144
+ * digest for is fetched as it always was.
145
+ */
146
+ const ARTIFACT_CACHE = 'volter-blender-artifacts';
147
+
148
+ async function cachedArtifact(file: string, digest: string | undefined): Promise<Response> {
149
+ const url = artifactUrl(file);
150
+ if (!digest || typeof caches === 'undefined') return fetch(url);
151
+ const key = `${url}?sha256=${digest}`;
152
+ const cache = await caches.open(ARTIFACT_CACHE);
153
+ const hit = await cache.match(key);
154
+ if (hit) return hit;
155
+ const response = await fetch(url);
156
+ if (!response.ok) return response;
157
+ // One build's bytes per file: an older build's are dropped as this one lands.
158
+ for (const old of await cache.keys()) if (old.url.startsWith(`${url}?sha256=`) && old.url !== key) await cache.delete(old);
159
+ const kept = new Response(await response.blob(), { headers: { 'content-type': response.headers.get('content-type') ?? 'application/octet-stream' } });
160
+ await cache.put(key, kept.clone());
161
+ return kept;
162
+ }
163
+
136
164
  export async function startEmscriptenBlenderEngine(
137
165
  options: BlenderEngineOptions,
138
- _status: BlenderArtifactStatus,
166
+ status: BlenderArtifactStatus,
139
167
  ): Promise<BlenderEngine> {
140
168
  if (!self.crossOriginIsolated)
141
169
  throw new Error(
@@ -144,7 +172,17 @@ export async function startEmscriptenBlenderEngine(
144
172
  'every response). This worker reports crossOriginIsolated=false.',
145
173
  );
146
174
  const glueUrl = artifactUrl('blender_browser.js');
147
- const factory = await loadFactory(glueUrl);
175
+ const digests = status.digests ?? {};
176
+ // The `.data` package is handed to the glue whole (`getPreloadedPackage`),
177
+ // so it is read before the module starts; the wasm streams in beside it.
178
+ const [factory, preloaded] = await Promise.all([
179
+ loadFactory(glueUrl),
180
+ cachedArtifact('blender_browser.data', digests['blender_browser.data']).then(async (response) => {
181
+ if (!response.ok) throw new Error(`blender_browser.data: HTTP ${response.status}`);
182
+ return response.arrayBuffer();
183
+ }),
184
+ ]);
185
+ let bootError: unknown = null;
148
186
  const started = performance.now();
149
187
  let readyLine: string | null = null;
150
188
  // BLENDER'S OWN STREAMS ARE PAGE OUTPUT, NOT EDITOR-CONSOLE CONDITIONS.
@@ -164,6 +202,16 @@ export async function startEmscriptenBlenderEngine(
164
202
  const module = await factory({
165
203
  arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
166
204
  locateFile: (file: string) => artifactUrl(file),
205
+ getPreloadedPackage: () => preloaded,
206
+ instantiateWasm: (imports: WebAssembly.Imports, receive: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void) => {
207
+ void (async () => {
208
+ const response = await cachedArtifact('blender_browser.wasm', digests['blender_browser.wasm']);
209
+ if (!response.ok) throw new Error(`blender_browser.wasm: HTTP ${response.status}`);
210
+ const { instance, module: compiled } = await WebAssembly.instantiateStreaming(response, imports);
211
+ receive(instance, compiled);
212
+ })().catch((error: unknown) => { bootError = error; });
213
+ return {};
214
+ },
167
215
  // The pthreads load the SAME patched glue this worker just evaluated.
168
216
  mainScriptUrlOrBlob: glueUrl,
169
217
  print: (text: string) => say('log', text),
@@ -209,7 +257,10 @@ export async function startEmscriptenBlenderEngine(
209
257
  });
210
258
  // `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
211
259
  // long before the session exists. The ready line is what says it does.
212
- while (readyLine === null) await sleep(5);
260
+ while (readyLine === null) {
261
+ if (bootError) throw bootError instanceof Error ? bootError : new Error(String(bootError));
262
+ await sleep(5);
263
+ }
213
264
  const bootMs = performance.now() - started;
214
265
  const banner = JSON.parse(readyLine) as { blender: string; engines: string[] };
215
266
 
@@ -268,7 +319,7 @@ export async function startEmscriptenBlenderEngine(
268
319
 
269
320
  const files = moduleFiles(module);
270
321
  FS.chmod('/bw/datafiles', 0o755);
271
- await mountEssentials(files);
322
+ await mountEssentials(files, digests['essentials.bin']);
272
323
  const { request } = openSessionChannel(files, options);
273
324
 
274
325
  return {
@@ -293,9 +344,9 @@ export async function startEmscriptenBlenderEngine(
293
344
  /** Assets are data, not a second engine. Ship them separately so a data update
294
345
  * does not relink the 86 MB Wasm binary. Both payload and per-file bounds are
295
346
  * checked before anything enters Blender's filesystem. */
296
- async function mountEssentials(files: BlenderFiles): Promise<void> {
347
+ async function mountEssentials(files: BlenderFiles, digest: string | undefined): Promise<void> {
297
348
  const [indexResponse, payloadResponse] = await Promise.all([
298
- fetch(artifactUrl('essentials.json')), fetch(artifactUrl('essentials.bin')),
349
+ fetch(artifactUrl('essentials.json')), cachedArtifact('essentials.bin', digest),
299
350
  ]);
300
351
  if (!indexResponse.ok || !payloadResponse.ok)
301
352
  throw new Error(`Blender Essentials assets are missing (${indexResponse.status}/${payloadResponse.status})`);
@@ -179,6 +179,8 @@ export interface BlenderArtifactStatus {
179
179
  encoded: Record<string, 'br'>;
180
180
  /** Why it is unavailable, named. Empty when it is available. */
181
181
  missing: string[];
182
+ /** Emscripten: each file's SHA-256 as it decodes, the key a page keeps it under. */
183
+ digests?: Record<string, string>;
182
184
  /** WALI only: the `sha256-` SRI of `blender.wasm`, which the program loader
183
185
  * requires for any non-blob URL. */
184
186
  integrity?: string;
@@ -202,6 +202,11 @@ export class BlenderRuntime {
202
202
  return this.#project;
203
203
  }
204
204
 
205
+ /** The startup document owned by this worker, including while boot is pending. */
206
+ get document(): string | null {
207
+ return this.#document;
208
+ }
209
+
205
210
  /** Idempotent: the first call boots Blender at `project`'s
206
211
  * absolute path; later calls await it (a different path is refused).
207
212
  *
@@ -429,10 +434,10 @@ export class BlenderRuntime {
429
434
  if (this.#terminated) return Promise.resolve();
430
435
  if (this.#stopping) return this.#stopping;
431
436
  this.#stopping = (async () => {
432
- if (this.#started) {
433
- await this.#started;
434
- await this.#request({ op: 'flush-document' }, true);
435
- }
437
+ // A start that failed opened no document, so there is nothing to flush: its stale
438
+ // rejection must not refuse every later close.
439
+ const started = this.#started ? await this.#started.then(() => true, () => false) : false;
440
+ if (started) await this.#request({ op: 'flush-document' }, true);
436
441
  this.terminate();
437
442
  })().catch(error => {
438
443
  this.#stopping = null;
@@ -465,7 +470,7 @@ export class BlenderRuntime {
465
470
  * A `present` is not counted separately — the worker only presents from
466
471
  * inside a call this meter is already holding open, and the page-side cost of
467
472
  * displaying the frame is main-thread time, which is the long-task observer's
468
- * subject (`packages/editor/src/blender-tab-metrics.ts`).
473
+ * subject (the host's `worker-call-metrics.ts`).
469
474
  */
470
475
  metrics(now: number = performance.now()): BlenderCallMetrics {
471
476
  let oldest: number | null = null;