@volter/blender-engine 0.1.45 → 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.
- package/browser/runtime.ts +6 -0
- package/browser/session.py +49 -0
- package/browser/three/blender-runtime-view.ts +40 -18
- package/browser/worker.ts +42 -7
- package/package.json +1 -1
package/browser/runtime.ts
CHANGED
|
@@ -225,6 +225,12 @@ export class BlenderRuntime {
|
|
|
225
225
|
return this.#document;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/** Blender's last lines of output (the worker's log, newest last): what a status read shows,
|
|
229
|
+
* including each document save's size and phase timings (`@@VOLTER-DOCUMENT`). */
|
|
230
|
+
get lastOutput(): readonly string[] {
|
|
231
|
+
return [...this.#lastLines];
|
|
232
|
+
}
|
|
233
|
+
|
|
228
234
|
/** Idempotent: the first call boots Blender at `project`'s
|
|
229
235
|
* absolute path; later calls await it (a different path is refused).
|
|
230
236
|
*
|
package/browser/session.py
CHANGED
|
@@ -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
|
-
/**
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
1617
|
-
if (!
|
|
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
|
|
1622
|
-
texture
|
|
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
|
-
|
|
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.
|
|
2038
|
-
frame.images[name] = {
|
|
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
|
@@ -198,9 +198,19 @@ function setDocumentDirty(dirty: boolean): void {
|
|
|
198
198
|
async function saveDocument(): Promise<void> {
|
|
199
199
|
if (!engine || documentPath === null) return;
|
|
200
200
|
const relative = documentPath;
|
|
201
|
+
// Each phase's milliseconds, reported with the save: on a large document the save is the
|
|
202
|
+
// edit's cost, and where it goes differs by host (a tab-served project runs the server here).
|
|
203
|
+
const ms: Record<string, number> = {};
|
|
204
|
+
let mark = performance.now();
|
|
205
|
+
const lap = (phase: string) => {
|
|
206
|
+
const now = performance.now();
|
|
207
|
+
ms[phase] = Math.round(now - mark);
|
|
208
|
+
mark = now;
|
|
209
|
+
};
|
|
201
210
|
let answer: { saved?: boolean; path?: string; size?: number };
|
|
202
211
|
try {
|
|
203
212
|
answer = (await engine.request({ op: 'save-document' })) as typeof answer;
|
|
213
|
+
lap('save');
|
|
204
214
|
} catch (error) {
|
|
205
215
|
// A document that cannot be written is the session's work at risk, so it
|
|
206
216
|
// is a named condition in the editor's console, not a debug line.
|
|
@@ -211,6 +221,7 @@ async function saveDocument(): Promise<void> {
|
|
|
211
221
|
let bytes: Uint8Array;
|
|
212
222
|
try {
|
|
213
223
|
bytes = await engine.files.readFile(answer.path);
|
|
224
|
+
lap('read');
|
|
214
225
|
} catch (error) {
|
|
215
226
|
throw new Error(`The Blender document ${relative} could not be read back out of the engine: ${describeThrown(error)}`);
|
|
216
227
|
}
|
|
@@ -222,6 +233,7 @@ async function saveDocument(): Promise<void> {
|
|
|
222
233
|
let sent = 0;
|
|
223
234
|
try {
|
|
224
235
|
const chunks = await documentChunks(bytes);
|
|
236
|
+
lap('chunk');
|
|
225
237
|
const query = `path=${encodeURIComponent(relative)}`;
|
|
226
238
|
const manifest = JSON.stringify({ chunks: chunks.map(({ hash, start, end }) => [hash, end - start]) });
|
|
227
239
|
// The manifest asks for what the server lacks; after those are sent it commits. A second
|
|
@@ -232,9 +244,15 @@ async function saveDocument(): Promise<void> {
|
|
|
232
244
|
headers: { 'content-type': 'application/json' },
|
|
233
245
|
body: manifest,
|
|
234
246
|
});
|
|
235
|
-
const said = (await posted.json().catch(() => null)) as
|
|
247
|
+
const said = (await posted.json().catch(() => null)) as
|
|
248
|
+
| { ok?: boolean; missing?: string[]; error?: string; ms?: Record<string, number> }
|
|
249
|
+
| null;
|
|
250
|
+
lap(`manifest${round}`);
|
|
236
251
|
if (!posted.ok) throw new Error(`HTTP ${posted.status} ${said?.error ?? ''}`);
|
|
237
|
-
if (said?.ok)
|
|
252
|
+
if (said?.ok) {
|
|
253
|
+
for (const [phase, value] of Object.entries(said.ms ?? {})) ms[`server.${phase}`] = value;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
238
256
|
if (round === 2 || !Array.isArray(said?.missing)) throw new Error(`the server still lacks ${said?.missing?.length ?? '?'} chunks`);
|
|
239
257
|
const wanted = new Set(said.missing);
|
|
240
258
|
for (const chunk of chunks) {
|
|
@@ -247,12 +265,13 @@ async function saveDocument(): Promise<void> {
|
|
|
247
265
|
if (!part.ok) throw new Error(`HTTP ${part.status} ${await part.text().catch(() => '')}`);
|
|
248
266
|
sent += chunk.end - chunk.start;
|
|
249
267
|
}
|
|
268
|
+
lap(`send${round}`);
|
|
250
269
|
}
|
|
251
270
|
} catch (error) {
|
|
252
271
|
throw new Error(`The Blender document ${relative} was not written to the project: ${describeThrown(error)}`);
|
|
253
272
|
}
|
|
254
273
|
setDocumentDirty(false);
|
|
255
|
-
log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length, sent })}`);
|
|
274
|
+
log('log', `@@VOLTER-DOCUMENT ${JSON.stringify({ path: relative, bytes: bytes.length, sent, ms })}`);
|
|
256
275
|
}
|
|
257
276
|
|
|
258
277
|
async function startBlender(project: string, document?: string): Promise<unknown> {
|
|
@@ -281,10 +300,26 @@ async function startBlender(project: string, document?: string): Promise<unknown
|
|
|
281
300
|
}
|
|
282
301
|
if (mesh !== undefined || image !== undefined) {
|
|
283
302
|
if (!pending) throw new Error(`The Blender session sent ${mesh ?? image} before its frame`);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
};
|
|
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
|
+
}
|
|
288
323
|
if (mesh !== undefined) pending.meshes.set(mesh, copied);
|
|
289
324
|
else pending.images.set(image as string, copied);
|
|
290
325
|
return {};
|