@volter/blender-engine 0.1.46 → 0.1.48

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.
@@ -143,6 +143,25 @@ REPLY = os.path.join(ROOT, "reply")
143
143
  # (`blender-wali-engine.mts`), where the file is not a fallback but the
144
144
  # native door onto the same bytes.
145
145
  EXPORT_BUFFER_PATH = os.environ.get("VOLTER_EXPORT_BUFFER_PATH", "")
146
+
147
+ # WHERE THE ENGINE'S HEAP WENT, stage by stage: the heap only grows, so its size after a stage says
148
+ # nothing about the stage before it; this records `_blender_web.memory()` at each, in MB
149
+ # ([label, heap, mallocInUse, mallocFree, guarded, guardedPeak]), and a script reads `MEMORY_MARKS`
150
+ # (the last 64) to say which stage raised the high-water mark. MEASURED 2026-09-29 on the
151
+ # Stoneguard file: the first export's door call took the heap from 1,442 to 3,644 MB while in-use
152
+ # rose 28 MB and the guarded peak did not move -- OpenSubdiv's limit-surface evaluator for one
153
+ # head (quality 3), which native Blender pays too (+1.2 GB peak RSS there).
154
+ MEMORY_MARKS = []
155
+
156
+
157
+ def _mark(label):
158
+ try:
159
+ reading = _blender_web.memory()
160
+ except Exception: # noqa: BLE001 -- an engine without the door records nothing
161
+ return
162
+ MEMORY_MARKS.append([label] + [round(reading.get(key, 0) / 1048576)
163
+ for key in ("heap", "mallocInUse", "mallocFree", "guarded", "guardedPeak")])
164
+ del MEMORY_MARKS[:-64]
146
165
  _real_stderr = sys.stderr
147
166
 
148
167
 
@@ -1358,6 +1377,7 @@ class Session:
1358
1377
  """
1359
1378
  scene = bpy.context.scene
1360
1379
  graphs = material_graphs(scene)
1380
+ _mark("export:graphs")
1361
1381
  # An IMAGE EMPTY's picture travels with the graphs' images: the overlay draws it
1362
1382
  # (`overlay_empty.hh` `image_sync`).
1363
1383
  empty_images = {obj.data.name for obj in scene.objects
@@ -1378,7 +1398,13 @@ class Session:
1378
1398
  # An engine without the pull door ships them all in this call, as before.
1379
1399
  if hasattr(_blender_web, "export_mesh"):
1380
1400
  options["defer"] = True
1381
- frame = json.loads(_blender_web.export_frame(json.dumps(options)))
1401
+ if hasattr(_blender_web, "memory_reset_peak"):
1402
+ _blender_web.memory_reset_peak()
1403
+ exported = _blender_web.export_frame(json.dumps(options))
1404
+ _mark("export:door")
1405
+ frame = json.loads(exported)
1406
+ del exported
1407
+ _mark("export:parsed")
1382
1408
  error = frame.get("error")
1383
1409
  if error:
1384
1410
  raise RuntimeError("Blender export door: %s" % error)
@@ -1507,8 +1533,11 @@ class Session:
1507
1533
 
1508
1534
  def _present(self, capture=None):
1509
1535
  known = dict(self._known)
1536
+ _mark("present:before")
1510
1537
  frame = self._export()
1538
+ _mark("present:exported")
1511
1539
  pulled = self._pull(frame)
1540
+ _mark("present:pulled")
1512
1541
  self._drop_unreachable_textures(frame)
1513
1542
  # WHAT THIS FRAME SHIPPED, for the session's own accounting: the ids
1514
1543
  # whose columns crossed, and the ids that went as a reference. The
@@ -1587,6 +1616,8 @@ class Session:
1587
1616
  for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
1588
1617
  ("image", images, _blender_web.export_image)):
1589
1618
  for key in keys:
1619
+ if kind == "image" and self._send_encoded_image(key, frame["images"][key]):
1620
+ continue
1590
1621
  options["key"] = key
1591
1622
  piece = json.loads(door(json.dumps(options)))
1592
1623
  if piece.get("error"):
@@ -1598,6 +1629,53 @@ class Session:
1598
1629
  ask({kind: key, "piece": piece[kind]})
1599
1630
  return True
1600
1631
 
1632
+ def _send_encoded_image(self, key, deferred):
1633
+ """A PICTURE AS ITS FILE'S OWN BYTES, when that is exactly what the frame would carry.
1634
+
1635
+ A modelling program keeps a picture compressed as its file and lets the GPU hold the
1636
+ decoded texture; only this side's raster door decoded every picture into RGBA and shipped
1637
+ that. MEASURED 2026-09-29 on the Stoneguard bridge: its 84 material pictures are 171 MB as
1638
+ their packed files and 1,343 MB as RGBA, and the hosted tab's renderer passed 10 GB while
1639
+ it opened. So a picture whose texels ARE its file -- a plain file or packed PNG or JPEG,
1640
+ not edited in this session -- is written to the engine's filesystem as those bytes and
1641
+ crosses as they are (`worker.ts`), decoded by the browser. The rest keep the raster door:
1642
+ a key with `@` (a roughness remap bakes new texels), a picture painted here (`is_dirty`),
1643
+ a UDIM or generated one, or bytes neither decoder reads.
1644
+
1645
+ Answers whether it was sent."""
1646
+ if "@" in key:
1647
+ return False
1648
+ image = bpy.data.images.get(key)
1649
+ if image is None or image.source != "FILE" or image.is_dirty:
1650
+ return False
1651
+ try:
1652
+ if image.packed_file is not None:
1653
+ data = bytes(image.packed_file.data)
1654
+ else:
1655
+ with open(bpy.path.abspath(image.filepath), "rb") as fh:
1656
+ data = fh.read()
1657
+ except (OSError, TypeError, ValueError):
1658
+ return False
1659
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
1660
+ mime = "image/png"
1661
+ elif data[:3] == b"\xff\xd8\xff":
1662
+ mime = "image/jpeg"
1663
+ else:
1664
+ return False
1665
+ path = os.path.join(ROOT, "encoded-image.bin")
1666
+ with open(path, "wb") as fh:
1667
+ fh.write(data)
1668
+ try:
1669
+ ask({"image": key, "piece": {
1670
+ "revision": deferred["revision"],
1671
+ "mime": mime,
1672
+ "colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" else "data",
1673
+ "encodedPath": path,
1674
+ }})
1675
+ finally:
1676
+ os.remove(path)
1677
+ return True
1678
+
1601
1679
  def _drop_unreachable_textures(self, frame):
1602
1680
  """A picture the export could not read is a WARNING, not a refusal.
1603
1681
 
@@ -1767,7 +1845,9 @@ class Session:
1767
1845
  self.document = os.path.join(self.project, relative_path)
1768
1846
  if not os.path.exists(self.document):
1769
1847
  return {"document": relative_path, "opened": False}
1848
+ _mark("open:before")
1770
1849
  bpy.ops.wm.open_mainfile(filepath=self.document)
1850
+ _mark("open:after")
1771
1851
  size = os.path.getsize(self.document)
1772
1852
  # THE STAGED COPY GOES ONCE BLENDER HAS READ IT. It sits in the engine's heap (WasmFS keeps
1773
1853
  # file data in linear memory) and nothing reads it again: the load took everything into
@@ -249,9 +249,13 @@ export async function texturesReady(): Promise<void> {
249
249
  while (pendingTextures.size > 0) await Promise.all([...pendingTextures]);
250
250
  }
251
251
 
252
- /** A PNG's bytes as a three texture, decoded by the browser's own decoder. */
253
- function loadPngTexture(png: Uint8Array): { texture: THREE.Texture; ready: Promise<void> } {
254
- const blob = new Blob([png as BlobPart], { type: 'image/png' });
252
+ /** An image file's own bytes (PNG or JPEG) as a three texture, decoded by the browser's own
253
+ * decoder. */
254
+ function loadEncodedTexture(
255
+ encoded: Uint8Array,
256
+ mime: 'image/png' | 'image/jpeg',
257
+ ): { texture: THREE.Texture; ready: Promise<void> } {
258
+ const blob = new Blob([encoded as BlobPart], { type: mime });
255
259
  const texture = new THREE.Texture();
256
260
  let disposed = false;
257
261
  texture.addEventListener('dispose', () => {
@@ -275,8 +279,14 @@ function loadPngTexture(png: Uint8Array): { texture: THREE.Texture; ready: Promi
275
279
  //
276
280
  // Its alpha is asked for here too: the browser's `default` premultiplies (Chrome's does), and
277
281
  // every reader of this texture takes the file's own texels, straight unless the image says it
278
- // stores them premultiplied, which the reader then undoes.
279
- const decoding = createImageBitmap(blob, { imageOrientation: 'flipY', premultiplyAlpha: 'none' })
282
+ // stores them premultiplied, which the reader then undoes. And its colour is the file's own: no
283
+ // embedded profile or gamma is applied, because Blender reads the texels as stored and applies
284
+ // the image's colour space setting itself, as the sampler does here.
285
+ const decoding = createImageBitmap(blob, {
286
+ imageOrientation: 'flipY',
287
+ premultiplyAlpha: 'none',
288
+ colorSpaceConversion: 'none',
289
+ })
280
290
  .then((bitmap) => {
281
291
  if (disposed) {
282
292
  bitmap.close();
@@ -392,14 +402,20 @@ const rasterImageSchema = z
392
402
  }).strict()).optional(),
393
403
  })
394
404
  .strict();
395
- const pngImageSchema = z
405
+ /** An image as its FILE'S OWN BYTES, PNG or JPEG (`session.py::_encoded_image`): what the file
406
+ * already carries compressed, decoded by the browser into the texture, so neither side holds
407
+ * the picture as raw RGBA. MEASURED 2026-09-29 on the Stoneguard bridge: its 84 material images
408
+ * are 171 MB as their packed files and 1,343 MB as RGBA. */
409
+ const encodedImageSchema = z
396
410
  .object({
397
411
  revision: z.number().int().nonnegative(),
398
- png: z.instanceof(Uint8Array).optional(),
399
- pngBase64: z.string().optional(),
412
+ encoded: z.instanceof(Uint8Array).optional(),
413
+ encodedBase64: z.string().optional(),
414
+ mime: z.enum(['image/png', 'image/jpeg']),
415
+ colorspace: z.enum(['sRGB', 'data']).optional(),
400
416
  })
401
417
  .strict();
402
- const frameImageSchema = z.union([rasterImageSchema, pngImageSchema]);
418
+ const frameImageSchema = z.union([rasterImageSchema, encodedImageSchema]);
403
419
 
404
420
  const textureReferenceSchema = z
405
421
  .object({
@@ -694,7 +710,7 @@ export class BlenderRuntimeView {
694
710
  width: number;
695
711
  height: number;
696
712
  revision: number;
697
- png?: Uint8Array;
713
+ encoded?: { bytes: Uint8Array; mime: 'image/png' | 'image/jpeg'; colorspace: 'sRGB' | 'data' };
698
714
  ready?: Promise<void>;
699
715
  /** A UDIM image's tiles as a graph samples them (`udimTextures`). */
700
716
  udim?: {tiles: THREE.DataArrayTexture; map: THREE.DataTexture; frame: NonNullable<z.infer<typeof rasterImageSchema>['tiles']>};
@@ -1609,24 +1625,25 @@ export class BlenderRuntimeView {
1609
1625
  });
1610
1626
  continue;
1611
1627
  }
1612
- // A FILE'S OWN PNG, decoded once here. Its size is the bitmap's, so the
1628
+ // A FILE'S OWN BYTES, decoded once here. Its size is the bitmap's, so the
1613
1629
  // cache records none: the next raster for this name, whatever its size,
1614
1630
  // is a rebuild rather than an upload into a texture whose dimensions
1615
1631
  // this side never learned.
1616
- const png = data.png ?? (data.pngBase64 ? bytesFromBase64(data.pngBase64) : undefined);
1617
- if (!png) throw new Error(`Runtime image ${name} carries no bytes`);
1632
+ const encoded = data.encoded ?? (data.encodedBase64 ? bytesFromBase64(data.encodedBase64) : undefined);
1633
+ if (!encoded) throw new Error(`Runtime image ${name} carries no bytes`);
1618
1634
  held?.texture.dispose();
1619
1635
  held?.udim?.tiles.dispose();
1620
1636
  held?.udim?.map.dispose();
1621
- const { texture, ready } = loadPngTexture(png);
1622
- texture.colorSpace = THREE.SRGBColorSpace;
1637
+ const colorspace = data.colorspace ?? 'sRGB';
1638
+ const { texture, ready } = loadEncodedTexture(encoded, data.mime);
1639
+ texture.colorSpace = colorspace === 'data' ? THREE.NoColorSpace : THREE.SRGBColorSpace;
1623
1640
  this.textures.set(name, {
1624
1641
  texture,
1625
1642
  ready,
1626
1643
  width: 0,
1627
1644
  height: 0,
1628
1645
  revision: data.revision,
1629
- png: png.slice(),
1646
+ encoded: { bytes: encoded.slice(), mime: data.mime, colorspace },
1630
1647
  });
1631
1648
  }
1632
1649
  for (const [id, data] of Object.entries(next.materials)) {
@@ -2034,8 +2051,13 @@ export class BlenderRuntimeView {
2034
2051
  for (const name of images) {
2035
2052
  const held = this.textures.get(name);
2036
2053
  if (!held) throw new Error(`Blender capture image ${name} is not resident`);
2037
- if (held.png) {
2038
- frame.images[name] = { revision: held.revision, png: held.png.slice() };
2054
+ if (held.encoded) {
2055
+ frame.images[name] = {
2056
+ revision: held.revision,
2057
+ encoded: held.encoded.bytes.slice(),
2058
+ mime: held.encoded.mime,
2059
+ colorspace: held.encoded.colorspace,
2060
+ };
2039
2061
  } else {
2040
2062
  const data = held.texture.image?.data;
2041
2063
  if (!(data instanceof Uint8Array))
package/browser/worker.ts CHANGED
@@ -300,10 +300,26 @@ async function startBlender(project: string, document?: string): Promise<unknown
300
300
  }
301
301
  if (mesh !== undefined || image !== undefined) {
302
302
  if (!pending) throw new Error(`The Blender session sent ${mesh ?? image} before its frame`);
303
- const copied = {
304
- typed: columnsToTypedArrays(arena, piece),
305
- description: await describeFrame(arena, piece),
306
- };
303
+ // A PICTURE AS ITS FILE'S OWN BYTES (`session.py::_encoded_image`): the session wrote
304
+ // them to `encodedPath` rather than decoding them into the arena, and they cross as they
305
+ // are; the record keeps their digest, as it does a column's.
306
+ const encodedPath = (piece as { encodedPath?: unknown } | undefined)?.encodedPath;
307
+ let copied: Copied;
308
+ if (image !== undefined && typeof encodedPath === 'string') {
309
+ const { encodedPath: _path, ...rest } = piece as Record<string, unknown>;
310
+ const bytes = (await holder.engine.files.readFile(encodedPath)).slice();
311
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
312
+ const sha256 = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
313
+ copied = {
314
+ typed: { ...rest, encoded: bytes },
315
+ description: { ...rest, encoded: { dtype: 'u8', length: bytes.length, sha256 } },
316
+ };
317
+ } else {
318
+ copied = {
319
+ typed: columnsToTypedArrays(arena, piece),
320
+ description: await describeFrame(arena, piece),
321
+ };
322
+ }
307
323
  if (mesh !== undefined) pending.meshes.set(mesh, copied);
308
324
  else pending.images.set(image as string, copied);
309
325
  return {};
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.46",
5
+ "version": "0.1.48",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },