@volter/blender-engine 0.1.49 → 0.1.51
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/blender-emscripten-engine.mts +28 -7
- package/browser/blender-engine.mts +3 -0
- package/browser/frame-transfer.mts +19 -0
- package/browser/session.py +16 -3
- package/browser/stream-file.mts +54 -0
- package/browser/worker.ts +22 -28
- package/package.json +1 -1
- package/wasm/BUNDLE.json +9 -11
- package/wasm/blender_browser.wasm.br +0 -0
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `session.py` chmods its own root as its first act.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { writeStreamedFile, type StreamFileSystem } from './stream-file.mts';
|
|
30
31
|
import {
|
|
31
32
|
artifactUrl,
|
|
32
33
|
type BlenderArtifactStatus,
|
|
@@ -44,7 +45,7 @@ import sessionPython from './session.py?raw';
|
|
|
44
45
|
|
|
45
46
|
/** What the module factory is, in the only shape this file uses. */
|
|
46
47
|
interface BlenderModule {
|
|
47
|
-
FS: {
|
|
48
|
+
FS: StreamFileSystem & {
|
|
48
49
|
readFile(path: string): Uint8Array;
|
|
49
50
|
writeFile(path: string, data: string | Uint8Array): void;
|
|
50
51
|
mkdirTree(path: string): void;
|
|
@@ -115,6 +116,7 @@ function moduleFiles(module: BlenderModule): BlenderFiles {
|
|
|
115
116
|
readFile: async (path) => FS.readFile(path),
|
|
116
117
|
// Not `async`: a write that throws throws here, so `request` never starts `.done` after a failed `.json`.
|
|
117
118
|
writeFile: (path, data) => { FS.writeFile(path, data); return Promise.resolve(); },
|
|
119
|
+
writeFileStream: (path, body, size) => writeStreamedFile(FS, path, body, size),
|
|
118
120
|
mkdirTree: async (path) => FS.mkdirTree(path),
|
|
119
121
|
readdir: async (path) => FS.readdir(path),
|
|
120
122
|
stat: async (path) => {
|
|
@@ -198,6 +200,21 @@ export async function startEmscriptenBlenderEngine(
|
|
|
198
200
|
if (text.startsWith('@@VOLTER-READY ')) readyLine = text.slice('@@VOLTER-READY '.length);
|
|
199
201
|
options.log(sessionLevel(text), text);
|
|
200
202
|
};
|
|
203
|
+
// THE HEAP ITSELF, not a view of it. Blender runs on a pthread, so when its heap grows there
|
|
204
|
+
// this worker's `module.HEAPU8` keeps the length it had until the glue next refreshes it -- and
|
|
205
|
+
// no export the door calls does. MEASURED 2026-09-29 on the Stoneguard scene from
|
|
206
|
+
// game-benchmarks main: a mesh's columns at 1.14 GB were read through a 433 MB view, and the
|
|
207
|
+
// pull failed "outside the export arena". The glue builds its memory with `new
|
|
208
|
+
// WebAssembly.Memory` and exports no handle to it, so that constructor is observed while the
|
|
209
|
+
// factory runs; every read below takes a fresh view of `memory.buffer`.
|
|
210
|
+
const Memory = WebAssembly.Memory;
|
|
211
|
+
let memory: WebAssembly.Memory | null = null;
|
|
212
|
+
(WebAssembly as { Memory: unknown }).Memory = function (descriptor: WebAssembly.MemoryDescriptor) {
|
|
213
|
+
const made = new Memory(descriptor);
|
|
214
|
+
memory ??= made;
|
|
215
|
+
return made;
|
|
216
|
+
} as unknown as typeof WebAssembly.Memory;
|
|
217
|
+
const heap = (): Uint8Array => (memory ? new Uint8Array(memory.buffer) : module.HEAPU8);
|
|
201
218
|
const module = await factory({
|
|
202
219
|
arguments: ['--background', '--factory-startup', '--python', SESSION_SCRIPT],
|
|
203
220
|
locateFile: (file: string) => artifactUrl(file),
|
|
@@ -253,6 +270,8 @@ export async function startEmscriptenBlenderEngine(
|
|
|
253
270
|
);
|
|
254
271
|
},
|
|
255
272
|
],
|
|
273
|
+
}).finally(() => {
|
|
274
|
+
(WebAssembly as { Memory: unknown }).Memory = Memory;
|
|
256
275
|
});
|
|
257
276
|
// `main()` runs on a pthread (`-sPROXY_TO_PTHREAD`), so the factory resolves
|
|
258
277
|
// long before the session exists. The ready line is what says it does.
|
|
@@ -329,13 +348,15 @@ export async function startEmscriptenBlenderEngine(
|
|
|
329
348
|
// A VIEW, NOT A COPY. The arena is wasm linear memory and under
|
|
330
349
|
// `-sPROXY_TO_PTHREAD` that memory is SHARED, so `session-frame.mts`
|
|
331
350
|
// slices every column it reads out of here; this call itself is free.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
)
|
|
351
|
+
// A DIRECT ARENA (address 0) IS THE WHOLE HEAP, at its length now: the size the door reports
|
|
352
|
+
// is `emscripten_get_heap_size()`, which is Blender's pthread's own view of the heap and is as
|
|
353
|
+
// stale as this worker's was (measured: 431 MB while a column lay at 2.0 GB).
|
|
354
|
+
readArena: async () => {
|
|
355
|
+
const base = module._blender_web_export_buffer();
|
|
356
|
+
return base === 0 ? heap() : heap().subarray(base, base + module._blender_web_export_buffer_size());
|
|
357
|
+
},
|
|
337
358
|
bootMs,
|
|
338
|
-
memoryBytes: () =>
|
|
359
|
+
memoryBytes: () => heap().length,
|
|
339
360
|
releasedPayloadBytes,
|
|
340
361
|
};
|
|
341
362
|
}
|
|
@@ -85,6 +85,9 @@ export interface BlenderFileStat {
|
|
|
85
85
|
export interface BlenderFiles {
|
|
86
86
|
readFile(path: string): Promise<Uint8Array>;
|
|
87
87
|
writeFile(path: string, data: string | Uint8Array): Promise<void>;
|
|
88
|
+
/** Stream a known-size file into the engine without a second whole-file JS buffer.
|
|
89
|
+
* A failed transfer leaves the previous file intact. */
|
|
90
|
+
writeFileStream?(path: string, body: ReadableStream<Uint8Array>, size: number): Promise<void>;
|
|
88
91
|
mkdirTree(path: string): Promise<void>;
|
|
89
92
|
readdir(path: string): Promise<string[]>;
|
|
90
93
|
/** Null when the path is not there. */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Buffers at every depth of a frame move to the presenter exactly once. */
|
|
2
|
+
export function frameTransferBuffers(frame: unknown): ArrayBuffer[] {
|
|
3
|
+
const buffers = new Set<ArrayBuffer>();
|
|
4
|
+
const visited = new Set<object>();
|
|
5
|
+
const walk = (value: unknown): void => {
|
|
6
|
+
if (value === null || typeof value !== 'object' || visited.has(value)) return;
|
|
7
|
+
visited.add(value);
|
|
8
|
+
if (value instanceof ArrayBuffer) {
|
|
9
|
+
buffers.add(value);
|
|
10
|
+
} else if (ArrayBuffer.isView(value)) {
|
|
11
|
+
// Shared Wasm memory cannot be transferred; arena readers make owned copies.
|
|
12
|
+
if (value.buffer instanceof ArrayBuffer) buffers.add(value.buffer);
|
|
13
|
+
} else {
|
|
14
|
+
for (const child of Object.values(value)) walk(child);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
walk(frame);
|
|
18
|
+
return [...buffers];
|
|
19
|
+
}
|
package/browser/session.py
CHANGED
|
@@ -162,6 +162,8 @@ def _mark(label):
|
|
|
162
162
|
MEMORY_MARKS.append([label] + [round(reading.get(key, 0) / 1048576)
|
|
163
163
|
for key in ("heap", "mallocInUse", "mallocFree", "guarded", "guardedPeak")])
|
|
164
164
|
del MEMORY_MARKS[:-64]
|
|
165
|
+
# Also said, so an engine that dies of memory names in its last output the stage it reached.
|
|
166
|
+
_say("@@VOLTER-MEMORY %s heap=%d inUse=%d free=%d" % tuple(MEMORY_MARKS[-1][:4]))
|
|
165
167
|
_real_stderr = sys.stderr
|
|
166
168
|
|
|
167
169
|
|
|
@@ -1611,7 +1613,7 @@ class Session:
|
|
|
1611
1613
|
images = [name for name, image in frame["images"].items() if image.get("deferred")]
|
|
1612
1614
|
if not meshes and not images:
|
|
1613
1615
|
return False
|
|
1614
|
-
|
|
1616
|
+
_asked({"hold": frame})
|
|
1615
1617
|
options = {"buffer_path": EXPORT_BUFFER_PATH} if EXPORT_BUFFER_PATH else {}
|
|
1616
1618
|
for kind, keys, door in (("mesh", meshes, _blender_web.export_mesh),
|
|
1617
1619
|
("image", images, _blender_web.export_image)):
|
|
@@ -1626,7 +1628,7 @@ class Session:
|
|
|
1626
1628
|
if piece[kind] is None:
|
|
1627
1629
|
del frame["images"][key]
|
|
1628
1630
|
continue
|
|
1629
|
-
|
|
1631
|
+
_asked({kind: key, "piece": piece[kind]})
|
|
1630
1632
|
return True
|
|
1631
1633
|
|
|
1632
1634
|
def _send_encoded_image(self, key, deferred):
|
|
@@ -1666,7 +1668,7 @@ class Session:
|
|
|
1666
1668
|
with open(path, "wb") as fh:
|
|
1667
1669
|
fh.write(data)
|
|
1668
1670
|
try:
|
|
1669
|
-
|
|
1671
|
+
_asked({"image": key, "piece": {
|
|
1670
1672
|
"revision": deferred["revision"],
|
|
1671
1673
|
"mime": mime,
|
|
1672
1674
|
"colorspace": "sRGB" if image.colorspace_settings.name == "sRGB" else "data",
|
|
@@ -2014,6 +2016,17 @@ def _pause(since):
|
|
|
2014
2016
|
time.sleep(0.0002 if time.monotonic() - since < _BRISK_SECONDS else 0.002)
|
|
2015
2017
|
|
|
2016
2018
|
|
|
2019
|
+
def _asked(payload):
|
|
2020
|
+
"""`ask`, failing as the tab failed: a piece the worker could not take comes back as
|
|
2021
|
+
`{"error": ...}`, and a pull that read past it presented a frame missing that piece, refused
|
|
2022
|
+
later under another name ("deferred and never pulled")."""
|
|
2023
|
+
answer = ask(payload)
|
|
2024
|
+
if isinstance(answer, dict) and answer.get("error"):
|
|
2025
|
+
raise RuntimeError("The tab refused %s: %s" % (
|
|
2026
|
+
next((key for key in ("mesh", "image", "hold") if key in payload), "an ask"), answer["error"]))
|
|
2027
|
+
return answer
|
|
2028
|
+
|
|
2029
|
+
|
|
2017
2030
|
def ask(payload):
|
|
2018
2031
|
"""Block until the tab answers. The worker's own thread is free while this
|
|
2019
2032
|
waits; only the Blender pthread is held, which is where the operator is.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** File imports publish only after every declared byte arrived. WasmFS stores the one final
|
|
2
|
+
* backing allocation; each write and its temporary Wasm copy are bounded to one MiB. */
|
|
3
|
+
export interface StreamFileSystem {
|
|
4
|
+
open(path: string, flags: string): { fd: number };
|
|
5
|
+
close(stream: { fd: number }): void;
|
|
6
|
+
write(stream: { fd: number }, data: Uint8Array, offset: number, length: number, position: number): number;
|
|
7
|
+
truncate(path: string, length: number): void;
|
|
8
|
+
rename(from: string, to: string): void;
|
|
9
|
+
unlink(path: string): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function writeStreamedFile(
|
|
13
|
+
FS: StreamFileSystem,
|
|
14
|
+
path: string,
|
|
15
|
+
body: ReadableStream<Uint8Array>,
|
|
16
|
+
size: number,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Error(`${path}: invalid file size ${size}`);
|
|
19
|
+
const temporary = `${path}.volter-stage-${crypto.randomUUID()}`;
|
|
20
|
+
const reader = body.getReader();
|
|
21
|
+
let stream: { fd: number } | undefined;
|
|
22
|
+
let complete = false;
|
|
23
|
+
try {
|
|
24
|
+
stream = FS.open(temporary, 'w');
|
|
25
|
+
// Reserve the exact backing once: growing WasmFS's vector while importing can otherwise
|
|
26
|
+
// hold both the old and the new allocation in the non-shrinking Wasm heap.
|
|
27
|
+
FS.truncate(temporary, size);
|
|
28
|
+
let position = 0;
|
|
29
|
+
for (;;) {
|
|
30
|
+
const { done, value } = await reader.read();
|
|
31
|
+
if (done) break;
|
|
32
|
+
if (position + value.byteLength > size) throw new Error(`${path}: file grew during import`);
|
|
33
|
+
for (let offset = 0; offset < value.byteLength;) {
|
|
34
|
+
const length = Math.min(1024 * 1024, value.byteLength - offset);
|
|
35
|
+
const written = FS.write(stream, value, offset, length, position);
|
|
36
|
+
if (written <= 0 || written > length) throw new Error(`${path}: invalid file write (${written})`);
|
|
37
|
+
offset += written;
|
|
38
|
+
position += written;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (position !== size) throw new Error(`${path}: expected ${size} bytes, received ${position}`);
|
|
42
|
+
FS.close(stream);
|
|
43
|
+
stream = undefined;
|
|
44
|
+
FS.rename(temporary, path);
|
|
45
|
+
complete = true;
|
|
46
|
+
} finally {
|
|
47
|
+
if (!complete) await reader.cancel().catch(() => {});
|
|
48
|
+
reader.releaseLock();
|
|
49
|
+
if (stream) FS.close(stream);
|
|
50
|
+
if (!complete) {
|
|
51
|
+
try { FS.unlink(temporary); } catch { /* open may have failed before creating the file */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
package/browser/worker.ts
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
import { type BlenderEngine, type BlenderFiles, startBlenderEngine } from './blender-engine.mts';
|
|
41
41
|
import type { CaptureRequest, FileEntry, WorkerReply, WorkerRequest } from './protocol';
|
|
42
|
+
import { frameTransferBuffers } from './frame-transfer.mts';
|
|
42
43
|
import { documentChunks } from './document-chunks.mts';
|
|
43
44
|
import { columnsToTypedArrays, describeFrame, isColumnDescriptor } from './session-frame.mts';
|
|
44
45
|
|
|
@@ -75,35 +76,24 @@ function presentToTab(
|
|
|
75
76
|
const id = ++presentId;
|
|
76
77
|
// A drawn mesh's buffers move to the tab rather than being copied: the
|
|
77
78
|
// frame is the tab's from here on.
|
|
78
|
-
const transfer
|
|
79
|
-
// A picture travels the same way and for the same reason: an entry in the
|
|
80
|
-
// frame's `images` carries a raw RGBA raster the tab uploads into a
|
|
81
|
-
// `DataTexture` (`blender-runtime-view.ts`), once per image per revision.
|
|
82
|
-
const parts = frame as {
|
|
83
|
-
meshes?: Record<string, unknown>;
|
|
84
|
-
images?: Record<string, unknown>;
|
|
85
|
-
} | null;
|
|
86
|
-
for (const carrier of [parts?.meshes ?? {}, parts?.images ?? {}])
|
|
87
|
-
for (const held of Object.values(carrier))
|
|
88
|
-
for (const value of Object.values(held as Record<string, unknown>))
|
|
89
|
-
if (
|
|
90
|
-
ArrayBuffer.isView(value) &&
|
|
91
|
-
value.buffer instanceof ArrayBuffer &&
|
|
92
|
-
!transfer.includes(value.buffer)
|
|
93
|
-
)
|
|
94
|
-
transfer.push(value.buffer);
|
|
79
|
+
const transfer = frameTransferBuffers(frame);
|
|
95
80
|
return new Promise((resolve, reject) => {
|
|
96
81
|
pendingPresents.set(id, { resolve, reject });
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
82
|
+
try {
|
|
83
|
+
(self as unknown as Worker).postMessage(
|
|
84
|
+
{
|
|
85
|
+
op: 'present',
|
|
86
|
+
id,
|
|
87
|
+
frame,
|
|
88
|
+
description,
|
|
89
|
+
...(capture ? { capture } : {}),
|
|
90
|
+
} satisfies WorkerReply,
|
|
91
|
+
transfer,
|
|
92
|
+
);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
pendingPresents.delete(id);
|
|
95
|
+
reject(error);
|
|
96
|
+
}
|
|
107
97
|
});
|
|
108
98
|
}
|
|
109
99
|
|
|
@@ -535,7 +525,11 @@ async function stageProjectFiles(files_: BlenderFiles, project: string): Promise
|
|
|
535
525
|
}
|
|
536
526
|
const dir = path.slice(0, path.lastIndexOf('/'));
|
|
537
527
|
if (dir) await files_.mkdirTree(dir);
|
|
538
|
-
|
|
528
|
+
if (files_.writeFileStream && answer.body) {
|
|
529
|
+
await files_.writeFileStream(path, answer.body, file.size);
|
|
530
|
+
} else {
|
|
531
|
+
await files_.writeFile(path, new Uint8Array(await answer.arrayBuffer()));
|
|
532
|
+
}
|
|
539
533
|
staged.set(path, { host: stamp, engine: await stampOf(files_, path) });
|
|
540
534
|
}
|
|
541
535
|
}
|
package/package.json
CHANGED
package/wasm/BUNDLE.json
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"blender": "5.2.0 LTS",
|
|
3
|
-
"source": "
|
|
4
|
-
"reviewedSource": "
|
|
3
|
+
"source": "e5d7eec67572dfc7c73f8f4d3f601d03442546f9",
|
|
4
|
+
"reviewedSource": "e5d7eec67572dfc7c73f8f4d3f601d03442546f9",
|
|
5
5
|
"upstream": "fbe6228777e7d9afefcd61a413844e790ae75db7",
|
|
6
6
|
"sourceRepository": "https://github.com/volter-ai/blender",
|
|
7
|
-
"sourceRecipe": "https://github.com/volter-ai/blender/blob/
|
|
8
|
-
"sourceRelease": "https://github.com/volter-ai/blender/releases/tag/blender-5.2.0-wasm.4",
|
|
9
|
-
"verification": "https://github.com/volter-ai/blender/releases/download/blender-5.2.0-wasm.4/VERIFICATION.json",
|
|
7
|
+
"sourceRecipe": "https://github.com/volter-ai/blender/blob/e5d7eec67572dfc7c73f8f4d3f601d03442546f9/build_files/emscripten/RELEASE.md",
|
|
10
8
|
"factoryEngine": "BLENDER_EEVEE",
|
|
11
9
|
"files": {
|
|
12
10
|
"blender_browser.js": "raw",
|
|
@@ -19,8 +17,8 @@
|
|
|
19
17
|
"sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
|
|
20
18
|
},
|
|
21
19
|
"blender_browser.wasm": {
|
|
22
|
-
"bytes":
|
|
23
|
-
"sha256": "
|
|
20
|
+
"bytes": 86317655,
|
|
21
|
+
"sha256": "0ba94ac8388de893bcec4da70516aea69128addaa490ef27f17ca06262ee6546"
|
|
24
22
|
},
|
|
25
23
|
"blender_browser.data": {
|
|
26
24
|
"bytes": 52698568,
|
|
@@ -33,8 +31,8 @@
|
|
|
33
31
|
"sha256": "0c63c29e7d4b1ba1fce4e94057778b0757767b25ea4f6f01d329e99351568f30"
|
|
34
32
|
},
|
|
35
33
|
"blender_browser.wasm.br": {
|
|
36
|
-
"bytes":
|
|
37
|
-
"sha256": "
|
|
34
|
+
"bytes": 18596913,
|
|
35
|
+
"sha256": "64af23954e1911ffcb879f69a9e8c9bbd83b5531939b586228f44fcea8211e3b"
|
|
38
36
|
},
|
|
39
37
|
"blender_browser.data.br": {
|
|
40
38
|
"bytes": 8056563,
|
|
@@ -45,9 +43,9 @@
|
|
|
45
43
|
"sha256": "4085e9336266cf66f061d4d5c962a5929cc96d5732c746b5c13ea05cd104a75b"
|
|
46
44
|
}
|
|
47
45
|
},
|
|
48
|
-
"note": "
|
|
46
|
+
"note": "Unreleased Stoneguard candidate. Clean source build at the named commit; Release -O3 objects and -O2 link, Emscripten 6.0.5. Focused geometry regression matches shipped engine across original, vertex edit and subdivision-level edit. Full historical battery is absent from current editor checkout; no full-battery claim.",
|
|
49
47
|
"callTrampoline": "CPython 3.13.13 is compiled with PY_CALL_TRAMPOLINE for Blender CPython slot arity casts. This applies to CPython dispatch, not arbitrary indirect calls.",
|
|
50
48
|
"engineIds": "The existing session registers Python render engines under CYCLES, BLENDER_EEVEE and BLENDER_WORKBENCH; the headless build permits built-in IDs to be shadowed.",
|
|
51
|
-
"exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build.",
|
|
49
|
+
"exportDoor": "The source-owned bpy_web_export.cc exports Principled physical inputs, normal images, eight named UV layers and evaluated corner normals, and takes graph_materials/graph_images/graph_generated (a graph-drawn shader mix reduced to the one Principled BSDF it reaches, graph images at the door revisions with every UDIM tile, orco for deformed meshes whose graph reads Generated coordinates). A frame can defer its changed meshes and pictures (defer) for the caller to pull one at a time (export_mesh, export_image), each call freeing the previous one's columns and a picture it decoded; an undo step keeps the revisions when Blender kept the depsgraph (session_undo); Blender's CPU subdivision descriptor is released once a mesh is read; the memory door reports malloc in-use and free. Datablock revisions are keyed by session_uid. writefile.cc writes one copy of the document per save in the browser build. CPU evaluators adopt stencil tables without cloning; browser subdivision descriptors are released immediately after CPU tessellation/deformation.",
|
|
52
50
|
"preloadedPayload": "releasePreloadedFileData releases the JS preload buffer after session ready. The measured working payload exclusions are retained."
|
|
53
51
|
}
|
|
Binary file
|