@volter/blender-engine 0.1.46 → 0.1.47

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.
@@ -1587,6 +1587,8 @@ class Session:
1587
1587
  for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
1588
1588
  ("image", images, _blender_web.export_image)):
1589
1589
  for key in keys:
1590
+ if kind == "image" and self._send_encoded_image(key, frame["images"][key]):
1591
+ continue
1590
1592
  options["key"] = key
1591
1593
  piece = json.loads(door(json.dumps(options)))
1592
1594
  if piece.get("error"):
@@ -1598,6 +1600,53 @@ class Session:
1598
1600
  ask({kind: key, "piece": piece[kind]})
1599
1601
  return True
1600
1602
 
1603
+ def _send_encoded_image(self, key, deferred):
1604
+ """A PICTURE AS ITS FILE'S OWN BYTES, when that is exactly what the frame would carry.
1605
+
1606
+ A modelling program keeps a picture compressed as its file and lets the GPU hold the
1607
+ decoded texture; only this side's raster door decoded every picture into RGBA and shipped
1608
+ that. MEASURED 2026-09-29 on the Stoneguard bridge: its 84 material pictures are 171 MB as
1609
+ their packed files and 1,343 MB as RGBA, and the hosted tab's renderer passed 10 GB while
1610
+ it opened. So a picture whose texels ARE its file -- a plain file or packed PNG or JPEG,
1611
+ not edited in this session -- is written to the engine's filesystem as those bytes and
1612
+ crosses as they are (`worker.ts`), decoded by the browser. The rest keep the raster door:
1613
+ a key with `@` (a roughness remap bakes new texels), a picture painted here (`is_dirty`),
1614
+ a UDIM or generated one, or bytes neither decoder reads.
1615
+
1616
+ Answers whether it was sent."""
1617
+ if "@" in key:
1618
+ return False
1619
+ image = bpy.data.images.get(key)
1620
+ if image is None or image.source != "FILE" or image.is_dirty:
1621
+ return False
1622
+ try:
1623
+ if image.packed_file is not None:
1624
+ data = bytes(image.packed_file.data)
1625
+ else:
1626
+ with open(bpy.path.abspath(image.filepath), "rb") as fh:
1627
+ data = fh.read()
1628
+ except (OSError, TypeError, ValueError):
1629
+ return False
1630
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
1631
+ mime = "image/png"
1632
+ elif data[:3] == b"\xff\xd8\xff":
1633
+ mime = "image/jpeg"
1634
+ else:
1635
+ return False
1636
+ path = os.path.join(ROOT, "encoded-image.bin")
1637
+ with open(path, "wb") as fh:
1638
+ fh.write(data)
1639
+ try:
1640
+ ask({"image": key, "piece": {
1641
+ "revision": deferred["revision"],
1642
+ "mime": mime,
1643
+ "colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" else "data",
1644
+ "encodedPath": path,
1645
+ }})
1646
+ finally:
1647
+ os.remove(path)
1648
+ return True
1649
+
1601
1650
  def _drop_unreachable_textures(self, frame):
1602
1651
  """A picture the export could not read is a WARNING, not a refusal.
1603
1652
 
@@ -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.47",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },