@volter/blender-engine 0.1.148 → 0.1.150
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/LICENSE +22 -5
- package/README.md +4 -11
- package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
- package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
- package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
- package/browser/addons/vrm/README.txt +3 -0
- package/browser/addons/vrm/__init__.py +41 -0
- package/browser/addons/vrm/blender_manifest.toml +16 -0
- package/browser/addons/vrm/common/__init__.py +1 -0
- package/browser/addons/vrm/common/animation.py +58 -0
- package/browser/addons/vrm/common/blender_manifest.py +82 -0
- package/browser/addons/vrm/common/char.py +16 -0
- package/browser/addons/vrm/common/convert.py +287 -0
- package/browser/addons/vrm/common/convert_any.py +48 -0
- package/browser/addons/vrm/common/debug.py +40 -0
- package/browser/addons/vrm/common/deep.py +162 -0
- package/browser/addons/vrm/common/error_dialog.py +274 -0
- package/browser/addons/vrm/common/fs.py +71 -0
- package/browser/addons/vrm/common/gl.py +20 -0
- package/browser/addons/vrm/common/gltf.py +1149 -0
- package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
- package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
- package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
- package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
- package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
- package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
- package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
- package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
- package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
- package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
- package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
- package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
- package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
- package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
- package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
- package/browser/addons/vrm/common/legacy_gltf.py +16 -0
- package/browser/addons/vrm/common/logger.py +84 -0
- package/browser/addons/vrm/common/mtoon0.blend +0 -0
- package/browser/addons/vrm/common/mtoon1.blend +0 -0
- package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
- package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
- package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
- package/browser/addons/vrm/common/ops/__init__.py +10 -0
- package/browser/addons/vrm/common/ops/export_scene.py +67 -0
- package/browser/addons/vrm/common/ops/icyp.py +53 -0
- package/browser/addons/vrm/common/ops/import_scene.py +57 -0
- package/browser/addons/vrm/common/ops/vrm.py +2380 -0
- package/browser/addons/vrm/common/ops/wm.py +167 -0
- package/browser/addons/vrm/common/preferences.py +362 -0
- package/browser/addons/vrm/common/progress.py +87 -0
- package/browser/addons/vrm/common/rotation.py +89 -0
- package/browser/addons/vrm/common/safe_removal.py +46 -0
- package/browser/addons/vrm/common/scene_watcher.py +230 -0
- package/browser/addons/vrm/common/shader.py +2243 -0
- package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
- package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
- package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
- package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
- package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
- package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
- package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
- package/browser/addons/vrm/common/version.py +161 -0
- package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
- package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
- package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
- package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
- package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
- package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
- package/browser/addons/vrm/common/workspace.py +227 -0
- package/browser/addons/vrm/common/writable_context.py +77 -0
- package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
- package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
- package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
- package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
- package/browser/addons/vrm/documentation/en-us/index.md +41 -0
- package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
- package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
- package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
- package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
- package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
- package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
- package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
- package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
- package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
- package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
- package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
- package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
- package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
- package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
- package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
- package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
- package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
- package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
- package/browser/addons/vrm/editor/__init__.py +1 -0
- package/browser/addons/vrm/editor/extension.py +425 -0
- package/browser/addons/vrm/editor/extension_accessor.py +75 -0
- package/browser/addons/vrm/editor/handler.py +84 -0
- package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
- package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
- package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
- package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
- package/browser/addons/vrm/editor/make_armature.py +709 -0
- package/browser/addons/vrm/editor/menu.py +147 -0
- package/browser/addons/vrm/editor/migration.py +297 -0
- package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
- package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
- package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
- package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
- package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
- package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
- package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
- package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
- package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
- package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
- package/browser/addons/vrm/editor/ops.py +471 -0
- package/browser/addons/vrm/editor/panel.py +315 -0
- package/browser/addons/vrm/editor/property_group.py +888 -0
- package/browser/addons/vrm/editor/search.py +682 -0
- package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
- package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
- package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
- package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
- package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
- package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
- package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
- package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
- package/browser/addons/vrm/editor/subscription.py +123 -0
- package/browser/addons/vrm/editor/t_pose.py +713 -0
- package/browser/addons/vrm/editor/validation.py +1271 -0
- package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
- package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
- package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
- package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
- package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
- package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
- package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
- package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
- package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
- package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
- package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
- package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
- package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
- package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
- package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
- package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
- package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
- package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
- package/browser/addons/vrm/exporter/__init__.py +1 -0
- package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
- package/browser/addons/vrm/exporter/export_scene.py +930 -0
- package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
- package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
- package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
- package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
- package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
- package/browser/addons/vrm/external/__init__.py +1 -0
- package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
- package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
- package/browser/addons/vrm/external/rigify_support.py +1 -0
- package/browser/addons/vrm/importer/__init__.py +1 -0
- package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
- package/browser/addons/vrm/importer/file_handler.py +34 -0
- package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
- package/browser/addons/vrm/importer/import_scene.py +646 -0
- package/browser/addons/vrm/importer/license_validation.py +198 -0
- package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
- package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
- package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
- package/browser/addons/vrm/importer/vrm_diff.py +316 -0
- package/browser/addons/vrm/locale/__init__.py +1 -0
- package/browser/addons/vrm/locale/ja_jp.py +826 -0
- package/browser/addons/vrm/locale/ruff.toml +8 -0
- package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
- package/browser/addons/vrm/locale/zh_hans.py +928 -0
- package/browser/addons/vrm/registration.py +670 -0
- package/browser/addons/vrm.json +18 -0
- package/browser/blender-emscripten-engine.mts +66 -129
- package/browser/blender-engine.mts +20 -186
- package/browser/blender-wali-engine.mts +37 -81
- package/browser/describe-thrown.mts +3 -6
- package/browser/document-chunks.mts +4 -16
- package/browser/gltf_export.py +3161 -0
- package/browser/heap-growth.mts +23 -0
- package/browser/index.ts +3 -0
- package/browser/protocol.ts +130 -43
- package/browser/pull-job.mts +2 -10
- package/browser/rna.ts +190 -73
- package/browser/runtime.ts +271 -106
- package/browser/session-addons.mts +33 -0
- package/browser/session-frame.mts +5 -24
- package/browser/session.py +1389 -1446
- package/browser/three/attach-presenter.ts +11 -39
- package/browser/three/blender-base64.ts +1 -36
- package/browser/three/blender-corner-normals.ts +1 -23
- package/browser/three/blender-display-shaders.json +1 -1
- package/browser/three/blender-display-shaders.lut +0 -0
- package/browser/three/blender-display-transform.ts +2 -1
- package/browser/three/blender-frame-columns.ts +5 -13
- package/browser/three/blender-graph-material.ts +18 -3
- package/browser/three/blender-node-graph.ts +10 -34
- package/browser/three/blender-physical-material.ts +2 -31
- package/browser/three/blender-runtime-armature.ts +7 -60
- package/browser/three/blender-runtime-frame.ts +9 -23
- package/browser/three/blender-runtime-geometry.ts +22 -26
- package/browser/three/blender-runtime-lighting.ts +48 -178
- package/browser/three/blender-runtime-shadows.ts +187 -1
- package/browser/three/blender-runtime-skeleton.ts +53 -1
- package/browser/three/blender-runtime-view.ts +492 -306
- package/browser/three/blender-runtime-weights.ts +9 -72
- package/browser/three/blender-sky.ts +13 -33
- package/browser/three/blender-triangulate.ts +7 -35
- package/browser/three/blender-viewport-sampling.ts +235 -0
- package/browser/three/blender-workbench-material.ts +25 -29
- package/browser/three/blender-workbench-outline.ts +199 -0
- package/browser/three/blender-world-volume.ts +62 -10
- package/browser/three/presenter.ts +10 -26
- package/browser/three/release.ts +5 -21
- package/browser/three/sky-precompute-worker.ts +1 -18
- package/browser/three/sky-worker.ts +5 -23
- package/browser/three/world-field-sampler.ts +12 -26
- package/browser/volter_three.py +9 -105
- package/browser/worker.ts +195 -180
- package/package.json +5 -2
- package/wasm/BUNDLE.json +17 -17
- package/wasm/DEPENDENCY-LICENSES.txt +14 -0
- package/wasm/blender_browser.data.br +0 -0
- package/wasm/blender_browser.js +1 -1
- package/wasm/blender_browser.wasm.br +0 -0
- package/browser/frame-transfer.mts +0 -19
- package/browser/three/agx-base-srgb.lut +0 -0
- package/browser/three/agx-look-medium-high-contrast.lut +0 -0
- package/browser/three/agx-look-punchy.lut +0 -0
- package/browser/three/blender-agx.ts +0 -235
- package/browser/three/blender-display-lut.ts +0 -145
- package/browser/three/blender-filmic.ts +0 -49
- package/browser/three/blender-standard.ts +0 -68
- package/browser/three/blender-world-visibility.ts +0 -278
- package/browser/three/filmic-srgb.lut +0 -0
package/browser/runtime.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { PullResult } from './pull-job.mts';
|
|
2
|
+
import { describeThrown } from './describe-thrown.mts';
|
|
2
3
|
import { FrameStreamReader } from './frame-stream.mts';
|
|
4
|
+
import { HEAP_GROWTH_REFUSED } from './protocol';
|
|
3
5
|
/**
|
|
4
|
-
* The tab-side handle on a Blender session: spawns the worker, forwards
|
|
5
|
-
*
|
|
6
|
-
* One instance per editor tab; the
|
|
6
|
+
* The tab-side handle on a Blender session: spawns the worker, forwards wire
|
|
7
|
+
* requests, and hands every presented frame to whoever displays it.
|
|
8
|
+
* One instance per editor tab; the documents it opens one after another share
|
|
9
|
+
* its Blender, and the session and its model die with the worker.
|
|
7
10
|
*/
|
|
8
11
|
import type {
|
|
9
12
|
CaptureRequest,
|
|
10
|
-
|
|
13
|
+
DocumentOpened,
|
|
11
14
|
RuntimeStart,
|
|
12
15
|
WorkerReply,
|
|
13
16
|
WorkerRequest,
|
|
@@ -15,9 +18,16 @@ import type {
|
|
|
15
18
|
NativePreviewRequest,
|
|
16
19
|
NativePreviewResult,
|
|
17
20
|
FileListSnapshot,
|
|
21
|
+
GltfExportAnswer,
|
|
22
|
+
GltfExportKind,
|
|
23
|
+
GltfExportPrevious,
|
|
24
|
+
ScriptRunAnswer,
|
|
25
|
+
BlenderAssetLibrary,
|
|
18
26
|
} from './protocol';
|
|
19
27
|
import type {
|
|
20
28
|
BlenderActionClip,
|
|
29
|
+
BlenderSceneMovie,
|
|
30
|
+
BlenderSoundScene,
|
|
21
31
|
BlenderNodeTree,
|
|
22
32
|
BlenderOutlinerTree,
|
|
23
33
|
BlenderOutlinerWrite,
|
|
@@ -30,6 +40,26 @@ import type {
|
|
|
30
40
|
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
|
31
41
|
type Request = DistributiveOmit<WorkerRequest, 'id'>;
|
|
32
42
|
|
|
43
|
+
|
|
44
|
+
/** An open that failed before Blender's own open (the path, the file's staging): Blender is as it was, holding the
|
|
45
|
+
* document it held. */
|
|
46
|
+
export class DocumentNotOpened extends Error {}
|
|
47
|
+
|
|
48
|
+
/** Calls a Blender giving up its document still takes ({@link BlenderRuntime.releaseDocument}): a backup's read of
|
|
49
|
+
* the document it saves, and an asset library in its preferences, which no file holds. Each runs in its turn,
|
|
50
|
+
* before the next document's open. */
|
|
51
|
+
const RELEASE_ADMITS: ReadonlySet<string> = new Set(['snapshot-document', 'asset-library']);
|
|
52
|
+
|
|
53
|
+
/** A script every save of a document runs in the save's own turn (`BlenderRuntime.saveDocument`), as the caller names
|
|
54
|
+
* it for the document saved: null for none. */
|
|
55
|
+
export type SaveScript = (document: string) => Promise<{ readonly source: string; readonly args: readonly string[] }> | null;
|
|
56
|
+
|
|
57
|
+
/** A save that landed: the document it wrote, and what its script did on the scene it wrote (null where none ran). */
|
|
58
|
+
export interface SavedDocument {
|
|
59
|
+
readonly document: string;
|
|
60
|
+
readonly script: ScriptRunAnswer | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
export interface BlenderRuntimeOptions {
|
|
34
64
|
/** Stage one verified mesh/image without replacing the displayed frame. */
|
|
35
65
|
stage?(part: { session: string; revision: number; evaluation?: 'render'; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
|
|
@@ -56,17 +86,14 @@ export interface BlenderRuntimeOptions {
|
|
|
56
86
|
* names the new file and before the call that moved it answers. An observer: a throw here
|
|
57
87
|
* changes nothing. */
|
|
58
88
|
documentMoved?(moved: { readonly from: string; readonly document: string }): void;
|
|
89
|
+
/** The worker's save refusal or its recovery, for the named project document. */
|
|
90
|
+
documentSaving?(state: { readonly document: string; readonly note: string | null }): void;
|
|
91
|
+
/** The document changed (`true`, at every change) or became its file again (`false`). An
|
|
92
|
+
* observer: a throw here changes nothing. */
|
|
93
|
+
documentDirty?(dirty: boolean): void;
|
|
59
94
|
}
|
|
60
95
|
|
|
61
|
-
/**
|
|
62
|
-
* What the tab answers a present with.
|
|
63
|
-
*
|
|
64
|
-
* `held` is WHAT THE PRESENTER HELD BEFORE THIS FRAME — the presenter's own
|
|
65
|
-
* report of its state, which the session compares against its record of what
|
|
66
|
-
* it sent (`session.py::_present`). `null` says it held nothing; an ABSENT
|
|
67
|
-
* field says this presenter does not report one (an older editor integration), and
|
|
68
|
-
* the session then judges nothing rather than resetting on every frame.
|
|
69
|
-
*/
|
|
96
|
+
/** The presenter’s state before this frame. held is null when it holds no frame; the session compares a reported state with what it sent. */
|
|
70
97
|
export interface PresentAnswer {
|
|
71
98
|
capture?: unknown;
|
|
72
99
|
held?: { session: string; revision: number } | null;
|
|
@@ -94,24 +121,7 @@ export interface ScreenshotView {
|
|
|
94
121
|
target?: number[];
|
|
95
122
|
}
|
|
96
123
|
|
|
97
|
-
/**
|
|
98
|
-
* HOW LONG THE WORKER TOOK, measured by the PAGE.
|
|
99
|
-
*
|
|
100
|
-
* WHY THE PAGE MEASURES (2026-09-16): a single `blender-execute` held the
|
|
101
|
-
* worker for over 1,800s and wedged the tab, and the only report anyone got
|
|
102
|
-
* was a replay harness timing out. A blocked worker cannot report on itself —
|
|
103
|
-
* the loop that would send the number is the loop that is stuck — so the one
|
|
104
|
-
* vantage point that still works is the side that POSTED the message. Every
|
|
105
|
-
* call goes through {@link BlenderRuntime.metrics}'s meter, which stamps the
|
|
106
|
-
* `postMessage` and the reply.
|
|
107
|
-
*
|
|
108
|
-
* MEASUREMENT ONLY. Nothing here cancels, kills or budgets a call: a budget is
|
|
109
|
-
* a policy decision, and this is the number a policy would have to be made
|
|
110
|
-
* from.
|
|
111
|
-
*
|
|
112
|
-
* Times are milliseconds on `performance.now()`. Counters are monotonic since
|
|
113
|
-
* this runtime was constructed (one per page load).
|
|
114
|
-
*/
|
|
124
|
+
/** Measure worker calls from the page between postMessage and reply, so a blocked worker remains observable. Metrics use performance.now milliseconds and monotonic counters for this runtime; they impose no call budget. */
|
|
115
125
|
export interface BlenderCallMetrics {
|
|
116
126
|
/** Age of the OLDEST outstanding call, or null when the worker is idle.
|
|
117
127
|
* This is the field that has a number during the wedge — the counters
|
|
@@ -124,10 +134,10 @@ export interface BlenderCallMetrics {
|
|
|
124
134
|
* exactly as long as it keeps running. */
|
|
125
135
|
readonly maxCallMs: number | null;
|
|
126
136
|
/** Operation and load boundary owning the longest request; no payload data. */
|
|
127
|
-
readonly maxCallLabel
|
|
137
|
+
readonly maxCallLabel: string | null;
|
|
128
138
|
/** Current cooperative phase and count of settled wire requests; sampled, not pushed per unit. */
|
|
129
|
-
readonly currentPhase
|
|
130
|
-
readonly completedCalls
|
|
139
|
+
readonly currentPhase: string | null;
|
|
140
|
+
readonly completedCalls: number;
|
|
131
141
|
/** Calls whose duration passed 5s, counting outstanding ones already past it. */
|
|
132
142
|
readonly callsOver5s: number;
|
|
133
143
|
/** The same at 30s — a call this side of it is slow, past it is a wedge. */
|
|
@@ -153,12 +163,22 @@ export interface BlenderCallMetrics {
|
|
|
153
163
|
* `blender_web_headless.cmake`). JavaScript cannot read a memory's maximum back. */
|
|
154
164
|
const ENGINE_MEMORY_CEILING_MB = 4096;
|
|
155
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Requests in OVERTAKES may overtake queued OVERTAKEN document reads. Export and read-file
|
|
168
|
+
* operations keep their order relative to edits; skin, movie, Properties, Outliner, node and UV
|
|
169
|
+
* inspections can yield their queue position.
|
|
170
|
+
*/
|
|
171
|
+
const OVERTAKES: ReadonlySet<string> = new Set(['gltf-export', 'read-file']);
|
|
172
|
+
const OVERTAKEN: ReadonlySet<string> = new Set(['action-clip', 'scene-movie', 'rna', 'rna-context', 'outliner', 'node-tree', 'uv-layout']);
|
|
173
|
+
|
|
156
174
|
export class BlenderRuntime {
|
|
157
175
|
readonly #worker: Worker;
|
|
158
176
|
readonly #options: BlenderRuntimeOptions;
|
|
159
177
|
/** Blender's own last output lines, which are what says why it died when it dies: they reach
|
|
160
178
|
* the page as ordinary output, so the failure below carries them. */
|
|
161
179
|
readonly #lastLines: string[] = [];
|
|
180
|
+
/** The line a thread printed when the browser refused to grow the heap, kept for the failure it causes. */
|
|
181
|
+
#growthRefused: string | null = null;
|
|
162
182
|
readonly #pending = new Map<
|
|
163
183
|
number,
|
|
164
184
|
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
|
|
@@ -166,10 +186,15 @@ export class BlenderRuntime {
|
|
|
166
186
|
#nextId = 0;
|
|
167
187
|
#started: Promise<RuntimeStart> | null = null;
|
|
168
188
|
#stopping: Promise<void> | null = null;
|
|
189
|
+
/** The script runs this runtime has made, each in a directory of its own. */
|
|
190
|
+
#scriptRuns = 0;
|
|
169
191
|
#terminated = false;
|
|
192
|
+
/** The document being given up for the next one ({@link releaseDocument}), and its last save. */
|
|
193
|
+
#release: { readonly document: string; readonly next: string; readonly saved: Promise<SavedDocument | null> } | null = null;
|
|
170
194
|
#dirty = false;
|
|
171
195
|
readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
|
|
172
|
-
|
|
196
|
+
// (a Blender holding no document has nothing to lose: its start is not work in progress)
|
|
197
|
+
if (this.#document === null || (!this.#dirty && this.#acceptedRequests === 0)) return;
|
|
173
198
|
event.preventDefault();
|
|
174
199
|
event.returnValue = '';
|
|
175
200
|
};
|
|
@@ -198,33 +223,28 @@ export class BlenderRuntime {
|
|
|
198
223
|
globalThis.addEventListener?.('beforeunload', this.#beforeUnload);
|
|
199
224
|
this.#worker.onmessage = (event: MessageEvent<WorkerReply>) => void this.#receive(event.data);
|
|
200
225
|
this.#worker.onerror = (event) => {
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// nothing. Every field the event carries goes in: the thrown error's own
|
|
204
|
-
// message and stack when there is one, and the file and line otherwise,
|
|
205
|
-
// which is what a failed nested import leaves behind.
|
|
226
|
+
// Worker errors include the thrown stack or message and the source location.
|
|
227
|
+
// Failed module imports may provide only the file and line.
|
|
206
228
|
const thrown = event.error as Error | undefined;
|
|
207
229
|
const where = event.filename ? ` at ${event.filename}:${event.lineno}:${event.colno}` : '';
|
|
208
230
|
const said = thrown?.stack ?? thrown?.message ?? event.message;
|
|
209
|
-
//
|
|
210
|
-
// Error ("Uncaught [object Object]"), while the reason (an allocation that failed, the file
|
|
211
|
-
// it was reading) is the last thing Blender printed.
|
|
231
|
+
// Include Blender's retained output alongside the worker error.
|
|
212
232
|
const last = this.#lastLines.length > 0 ? `\nBlender's last output:\n${this.#lastLines.join('\n')}` : '';
|
|
213
233
|
// OUT OF MEMORY IS SAID AS SUCH, from the heap the worker reported as the thread died.
|
|
214
234
|
const heapMB = this.#wasmBytes === null ? null : Math.round(this.#wasmBytes / 1048576);
|
|
215
|
-
const
|
|
216
|
-
|
|
235
|
+
const refused = this.#growthRefused;
|
|
236
|
+
const exhausted = refused !== null
|
|
237
|
+
? `Blender ran out of memory: the browser would not give its heap more (${refused.slice(HEAP_GROWTH_REFUSED.length + 2)}), ` +
|
|
238
|
+
"below the in-tab engine's ceiling, which happens when the machine itself is short of memory. " +
|
|
239
|
+
'Free memory (other editors, tabs, builds) and run the call again. '
|
|
240
|
+
: heapMB !== null && heapMB >= ENGINE_MEMORY_CEILING_MB - 16
|
|
217
241
|
? `Blender ran out of memory: its heap reached ${heapMB} MB of the in-tab engine's ` +
|
|
218
242
|
`${ENGINE_MEMORY_CEILING_MB} MB, and this scene needs more. `
|
|
219
243
|
: '';
|
|
220
244
|
const error = new Error(
|
|
221
245
|
`${exhausted}Blender worker failed: ${said || 'the worker script did not load'}${where}${last}`,
|
|
222
246
|
);
|
|
223
|
-
//
|
|
224
|
-
// captures it, source-blind), and this package may import nothing of the
|
|
225
|
-
// editor to say it any other way. Without this line the failure reached
|
|
226
|
-
// a toast and `cyclotron console` read 0/0 while no Model document could open
|
|
227
|
-
// (measured 2026-09-20 from a registry install).
|
|
247
|
+
// Report initialization failures to the page console, which the editor captures into its session ledger.
|
|
228
248
|
console.error(error.message);
|
|
229
249
|
for (const id of [...this.#pending.keys()]) this.#settled(id);
|
|
230
250
|
for (const pending of this.#pending.values()) pending.reject(error);
|
|
@@ -241,9 +261,10 @@ export class BlenderRuntime {
|
|
|
241
261
|
return this.#project;
|
|
242
262
|
}
|
|
243
263
|
|
|
244
|
-
/** The document owned by this worker,
|
|
245
|
-
* a script's Save As leaves Blender holding another
|
|
246
|
-
* it there (`session.py::follow_open_file`).
|
|
264
|
+
/** The document owned by this worker, from the moment its open is asked for: the one
|
|
265
|
+
* {@link openDocument} last named, until a script's Save As leaves Blender holding another
|
|
266
|
+
* project `.blend` and the session follows it there (`session.py::follow_open_file`). Null
|
|
267
|
+
* while Blender holds none. */
|
|
247
268
|
get document(): string | null {
|
|
248
269
|
return this.#document;
|
|
249
270
|
}
|
|
@@ -254,30 +275,15 @@ export class BlenderRuntime {
|
|
|
254
275
|
return [...this.#lastLines];
|
|
255
276
|
}
|
|
256
277
|
|
|
257
|
-
/** Idempotent: the first call boots Blender at `project`'s
|
|
258
|
-
*
|
|
259
|
-
|
|
260
|
-
* `document` is the session's `.blend`, project-relative. An explicit
|
|
261
|
-
* different file is a resource conflict, including while boot is pending.
|
|
262
|
-
* Omitting it on an already-started handle means await that same session. */
|
|
263
|
-
start(project: string, document?: string): Promise<RuntimeStart> {
|
|
278
|
+
/** Idempotent: the first call boots Blender at `project`'s absolute path, holding no
|
|
279
|
+
* document; later calls await it (a different path is refused). */
|
|
280
|
+
start(project: string): Promise<RuntimeStart> {
|
|
264
281
|
if (this.#project !== null && this.#project !== project)
|
|
265
282
|
return Promise.reject(
|
|
266
283
|
new Error(`The Blender session is bound to ${this.#project}, not ${project}`),
|
|
267
284
|
);
|
|
268
|
-
if (document !== undefined && this.#document !== null && this.#document !== document)
|
|
269
|
-
return Promise.reject(
|
|
270
|
-
new Error(
|
|
271
|
-
`Blender resource conflict: the session holds ${this.#document}; cannot open ${document}`,
|
|
272
|
-
),
|
|
273
|
-
);
|
|
274
285
|
this.#project = project;
|
|
275
|
-
this.#
|
|
276
|
-
this.#started ??= (this.#request({
|
|
277
|
-
op: 'start',
|
|
278
|
-
project,
|
|
279
|
-
...(document === undefined ? {} : { document }),
|
|
280
|
-
}) as Promise<RuntimeStart>).catch((error: unknown) => {
|
|
286
|
+
this.#started ??= (this.#request({ op: 'start', project }) as Promise<RuntimeStart>).catch((error: unknown) => {
|
|
281
287
|
this.#startFailed = true;
|
|
282
288
|
throw error;
|
|
283
289
|
});
|
|
@@ -436,6 +442,39 @@ export class BlenderRuntime {
|
|
|
436
442
|
}
|
|
437
443
|
|
|
438
444
|
|
|
445
|
+
/** Sampled scene animation for Timeline preview and export (`BlenderSceneMovie`). */
|
|
446
|
+
async sceneMovie(): Promise<BlenderSceneMovie> {
|
|
447
|
+
await this.#ready();
|
|
448
|
+
return (await this.#request({ op: 'scene-movie' })) as BlenderSceneMovie;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** THE SCENE'S SOUND for frames `first` to `last` (`./rna.ts`, `BlenderSoundScene`), for the page to mix. */
|
|
452
|
+
async soundScene(first: number, last: number): Promise<BlenderSoundScene> {
|
|
453
|
+
await this.#ready();
|
|
454
|
+
return (await this.#request({ op: 'sound-scene', first, last })) as BlenderSoundScene;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Export the document as a component or level through Blender’s glTF exporter into the module
|
|
460
|
+
* filesystem. The answer names the files for the caller to carry into the project; the call holds
|
|
461
|
+
* the session until completion.
|
|
462
|
+
*/
|
|
463
|
+
async gltfExport(options: { readonly kind: GltfExportKind; readonly force?: boolean; readonly previous?: GltfExportPrevious; readonly blendSha256?: string; readonly luts?: Readonly<Record<string, string>> }): Promise<GltfExportAnswer> {
|
|
464
|
+
await this.#ready();
|
|
465
|
+
return (await this.#request({ op: 'gltf-export', kind: options.kind,
|
|
466
|
+
...(options.force ? { force: true } : {}), ...(options.previous ? { previous: options.previous } : {}),
|
|
467
|
+
...(options.blendSha256 ? { blendSha256: options.blendSha256 } : {}), ...(options.luts ? { luts: options.luts } : {}) })) as GltfExportAnswer;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
/** An asset library in Blender's preferences, set again under its name (Preferences > File Paths). */
|
|
473
|
+
async setAssetLibrary(library: BlenderAssetLibrary): Promise<void> {
|
|
474
|
+
await this.#ready();
|
|
475
|
+
await this.#request({ op: 'asset-library', library });
|
|
476
|
+
}
|
|
477
|
+
|
|
439
478
|
/** Write ONE restriction column (the eye, the render camera, a collection's
|
|
440
479
|
* Exclude). A column Blender draws on no row of that type is refused by
|
|
441
480
|
* name rather than written somewhere near it. */
|
|
@@ -449,16 +488,7 @@ export class BlenderRuntime {
|
|
|
449
488
|
})) as BlenderOutlinerWrite;
|
|
450
489
|
}
|
|
451
490
|
|
|
452
|
-
/**
|
|
453
|
-
* PRESENT WHAT THE ENGINE HOLDS, with no capture.
|
|
454
|
-
*
|
|
455
|
-
* Every mutation presents (`session.py::dispatch`), so in the ordinary
|
|
456
|
-
* course of a session nothing needs to ask. The exception is the first
|
|
457
|
-
* frame: opening a `.blend` is not a mutation, so a freshly opened Model
|
|
458
|
-
* document had nothing to display until something RAN — measured by I1 and
|
|
459
|
-
* fixed here (WORK.md §Blender in the tab is Blender, "Inspection parity",
|
|
460
|
-
* I2 decision 6). The document asks on mount.
|
|
461
|
-
*/
|
|
491
|
+
/** Present the engine’s current state without capture. Documents ask on mount so an opened .blend draws before its first mutation. */
|
|
462
492
|
async present(): Promise<void> {
|
|
463
493
|
await this.#ready();
|
|
464
494
|
await this.#request({ op: 'present' });
|
|
@@ -472,6 +502,80 @@ export class BlenderRuntime {
|
|
|
472
502
|
| { error: string };
|
|
473
503
|
}
|
|
474
504
|
|
|
505
|
+
/** Whether the document holds changes its project file does not. */
|
|
506
|
+
get dirty(): boolean {
|
|
507
|
+
return this.#dirty;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** A save of the document, and in the same turn, before any other request reaches this Blender, the script `after`
|
|
511
|
+
* names for the document it wrote (as the worker holds it once saved, a move accepted before it included), run on
|
|
512
|
+
* the scene just saved, in a working directory of its own, its files carried back in its answer. Null when there
|
|
513
|
+
* was nothing to save. While the document is being given up, the save is the release's own: this resolves when it
|
|
514
|
+
* lands, with null (its listeners hear it once, from the release), and fails with it. */
|
|
515
|
+
async saveDocument(after?: SaveScript): Promise<SavedDocument | null> {
|
|
516
|
+
await this.#ready();
|
|
517
|
+
if (this.#release !== null) return this.#release.saved.then(() => null);
|
|
518
|
+
return this.#inTurn('flush-document', false, () => this.#save(after));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** The document's bytes as a save would write them, carried nowhere. */
|
|
522
|
+
async snapshotDocument(): Promise<Uint8Array> {
|
|
523
|
+
await this.#ready();
|
|
524
|
+
return (await this.#request({ op: 'snapshot-document' })) as Uint8Array;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Open `document` (project-relative) in this Blender, once it has started, as its File > Open does: from
|
|
529
|
+
* the project's file, or as `bytes`, its unsaved state (a backup). Naming the document it holds reopens
|
|
530
|
+
* it, discarding its changes (a revert, a restore). The handle claims the document at the call, so a
|
|
531
|
+
* request made after it waits for the open and finds it; the one held before is the caller's to save
|
|
532
|
+
* first ({@link releaseDocument}). A failure before Blender's own open rejects with {@link DocumentNotOpened}, and
|
|
533
|
+
* the handle names the document it held again.
|
|
534
|
+
*/
|
|
535
|
+
openDocument(document: string, bytes?: Uint8Array): Promise<DocumentOpened> {
|
|
536
|
+
const held = this.#document;
|
|
537
|
+
this.#document = document;
|
|
538
|
+
const request = { op: 'open-document', document, ...(bytes === undefined ? {} : { bytes }) } as const;
|
|
539
|
+
return this.#ready().then(() => this.#inTurn('open-document', false, async () => {
|
|
540
|
+
// The open the release was for: the document given up is no longer held.
|
|
541
|
+
if (this.#release?.next === document) this.#release = null;
|
|
542
|
+
// From this turn on, a frame presented is the opened file's: the last one's is no longer on screen as its.
|
|
543
|
+
this.#presented = null;
|
|
544
|
+
const answer = (await this.#wire(request)) as DocumentOpened | { readonly notOpened: string };
|
|
545
|
+
if (!('notOpened' in answer)) return answer;
|
|
546
|
+
if (this.#document === document) this.#document = held;
|
|
547
|
+
throw new DocumentNotOpened(answer.notOpened);
|
|
548
|
+
}, document));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* THE DOCUMENT GIVEN UP FOR `next`, ITS LAST SAVE FIRST, as a stop's is: queued behind every request accepted before
|
|
553
|
+
* it, with `retire.after`'s script in its turn, heard by `landed`. From the call on, no edit is taken, so none lands
|
|
554
|
+
* after the save, until `next`'s {@link openDocument} has its turn or {@link keepDocument} returns to the document;
|
|
555
|
+
* a save asked for meanwhile is this one, and a backup's read and an asset library still run in their turns
|
|
556
|
+
* (`RELEASE_ADMITS`). A save that fails, or a listener that throws, keeps the document, and edits are taken again.
|
|
557
|
+
*/
|
|
558
|
+
releaseDocument(next: string, retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
|
|
559
|
+
if (this.#terminated || this.#stopping) return Promise.reject(new Error('The Blender session is stopping or terminated'));
|
|
560
|
+
const document = this.#document;
|
|
561
|
+
if (document === null) return Promise.resolve();
|
|
562
|
+
const saved = this.#ready().then(() => this.#inTurn('flush-document', true, () => this.#save(retire?.after)));
|
|
563
|
+
const release = { document, next, saved };
|
|
564
|
+
this.#release = release;
|
|
565
|
+
return saved
|
|
566
|
+
.then((value) => { if (value !== null) retire?.landed(value); })
|
|
567
|
+
.catch((error: unknown) => {
|
|
568
|
+
if (this.#release === release) this.#release = null;
|
|
569
|
+
throw error;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The document given up ({@link releaseDocument}) is kept after all, while the handle still names it: edits are
|
|
574
|
+
* taken again. Once the next document's open is asked for, that open ends the release instead. */
|
|
575
|
+
keepDocument(): void {
|
|
576
|
+
if (this.#release !== null && this.#release.document === this.#document) this.#release = null;
|
|
577
|
+
}
|
|
578
|
+
|
|
475
579
|
async readFile(path: string): Promise<Uint8Array> {
|
|
476
580
|
await this.#ready();
|
|
477
581
|
return (await this.#request({ op: 'read-file', path })) as Uint8Array;
|
|
@@ -482,11 +586,6 @@ export class BlenderRuntime {
|
|
|
482
586
|
await this.#request({ op: 'write-file', path, bytes });
|
|
483
587
|
}
|
|
484
588
|
|
|
485
|
-
async listFiles(path: string): Promise<FileEntry[]> {
|
|
486
|
-
await this.#ready();
|
|
487
|
-
return (await this.#request({ op: 'list-files', path })) as FileEntry[];
|
|
488
|
-
}
|
|
489
|
-
|
|
490
589
|
/** Files and their native document identity, without executing Python or
|
|
491
590
|
* exporting/saving the model. The worker serializes this with authoring. */
|
|
492
591
|
async listFilesSnapshot(path: string): Promise<FileListSnapshot> {
|
|
@@ -501,15 +600,23 @@ export class BlenderRuntime {
|
|
|
501
600
|
}
|
|
502
601
|
|
|
503
602
|
/** Drain accepted calls and persist the document before destroying its only
|
|
504
|
-
* copy. A failed save keeps the worker alive so the caller can retry.
|
|
505
|
-
|
|
603
|
+
* copy. A failed save keeps the worker alive so the caller can retry. With `retire`, the stop's save is the caller's
|
|
604
|
+
* too: it runs `retire.after`'s script in its turn, as every save does (`saveDocument`), and `landed` hears it. */
|
|
605
|
+
stop(retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
|
|
506
606
|
if (this.#terminated) return Promise.resolve();
|
|
507
607
|
if (this.#stopping) return this.#stopping;
|
|
608
|
+
// Holding no document, it has nothing to save, and its start is not waited for.
|
|
609
|
+
if (this.#document === null) {
|
|
610
|
+
this.terminate();
|
|
611
|
+
return Promise.resolve();
|
|
612
|
+
}
|
|
508
613
|
this.#stopping = (async () => {
|
|
509
614
|
// A start that failed opened no document, so there is nothing to flush: its stale
|
|
510
615
|
// rejection must not refuse every later close.
|
|
511
616
|
const started = this.#started ? await this.#started.then(() => true, () => false) : false;
|
|
512
|
-
|
|
617
|
+
// (one save, queued behind every request accepted before the stop; none is taken after it)
|
|
618
|
+
const saved = started ? await this.#inTurn('flush-document', true, () => this.#save(retire?.after)) : null;
|
|
619
|
+
if (saved !== null) retire?.landed(saved);
|
|
513
620
|
this.terminate();
|
|
514
621
|
})().catch(error => {
|
|
515
622
|
this.#stopping = null;
|
|
@@ -518,6 +625,23 @@ export class BlenderRuntime {
|
|
|
518
625
|
return this.#stopping;
|
|
519
626
|
}
|
|
520
627
|
|
|
628
|
+
/** The save, and its script on the scene it wrote, inside one turn. */
|
|
629
|
+
async #save(after: SaveScript | undefined): Promise<SavedDocument | null> {
|
|
630
|
+
// The document the worker wrote, as it says: this handle may already name the next one.
|
|
631
|
+
const { saved, document } = (await this.#wire({ op: 'flush-document' })) as { saved: boolean; document?: string };
|
|
632
|
+
if (!saved || document === undefined) return null;
|
|
633
|
+
const named = after?.(document) ?? null;
|
|
634
|
+
if (named === null) return { document, script: null };
|
|
635
|
+
let script: { readonly source: string; readonly args: readonly string[] };
|
|
636
|
+
try {
|
|
637
|
+
script = await named;
|
|
638
|
+
} catch (error) {
|
|
639
|
+
return { document, script: { output: '', error: `the save's script could not be read: ${describeThrown(error)}`, files: [], unstaged: [] } };
|
|
640
|
+
}
|
|
641
|
+
const dir = `/tmp/volter-script-run/${++this.#scriptRuns}`;
|
|
642
|
+
return { document, script: (await this.#wire({ op: 'run-script', source: script.source, args: script.args, dir })) as ScriptRunAnswer };
|
|
643
|
+
}
|
|
644
|
+
|
|
521
645
|
/** Forced teardown for a lost session; explicit user stops must use stop(). */
|
|
522
646
|
terminate(): void {
|
|
523
647
|
this.#discardStagedFrame();
|
|
@@ -546,7 +670,8 @@ export class BlenderRuntime {
|
|
|
546
670
|
* displaying the frame is main-thread time, which is the long-task observer's
|
|
547
671
|
* subject (the host's `worker-call-metrics.ts`).
|
|
548
672
|
*/
|
|
549
|
-
metrics(
|
|
673
|
+
metrics(): BlenderCallMetrics {
|
|
674
|
+
const now = performance.now();
|
|
550
675
|
let oldest: number | null = null;
|
|
551
676
|
let max = this.#maxCompletedMs;
|
|
552
677
|
let maxLabel = this.#maxCompletedLabel;
|
|
@@ -594,33 +719,64 @@ export class BlenderRuntime {
|
|
|
594
719
|
|
|
595
720
|
#completedCalls = 0;
|
|
596
721
|
#acceptedRequests = 0;
|
|
597
|
-
|
|
598
|
-
|
|
722
|
+
/**
|
|
723
|
+
* Process requests in queue order. gltf-export and read-file may overtake queued document reads
|
|
724
|
+
* in OVERTAKEN, while preserving the order of edits and other operations.
|
|
725
|
+
*/
|
|
726
|
+
#waiting: { readonly op: string; readonly start: () => void }[] = [];
|
|
727
|
+
#busy = false;
|
|
728
|
+
#enqueue(op: string, start: () => void): void {
|
|
729
|
+
const item = { op, start };
|
|
730
|
+
if (OVERTAKES.has(op)) {
|
|
731
|
+
let at = this.#waiting.length;
|
|
732
|
+
while (at > 0 && OVERTAKEN.has(this.#waiting[at - 1]!.op)) at--;
|
|
733
|
+
this.#waiting.splice(at, 0, item);
|
|
734
|
+
} else this.#waiting.push(item);
|
|
735
|
+
if (!this.#busy) this.#next();
|
|
736
|
+
}
|
|
737
|
+
#next(): void {
|
|
738
|
+
const item = this.#waiting.shift();
|
|
739
|
+
this.#busy = item !== undefined;
|
|
740
|
+
item?.start();
|
|
741
|
+
}
|
|
742
|
+
#request(request: Request): Promise<unknown> {
|
|
743
|
+
return this.#inTurn(request.op, false, () => this.#wire(request));
|
|
744
|
+
}
|
|
745
|
+
/** One turn of the queue: `run` alone talks to the worker until it is done, whatever it sends. */
|
|
746
|
+
#inTurn<T>(op: string, shutdown: boolean, run: () => Promise<T>, opens?: string): Promise<T> {
|
|
599
747
|
if (this.#terminated || (this.#stopping && !shutdown))
|
|
600
748
|
return Promise.reject(new Error('The Blender session is stopping or terminated'));
|
|
749
|
+
const release = this.#release;
|
|
750
|
+
if (release !== null && !shutdown && !RELEASE_ADMITS.has(op) && opens !== release.next)
|
|
751
|
+
return Promise.reject(new Error(`Blender is saving ${release.document} before opening ${release.next}, and takes no edit until ${release.next} is open.`));
|
|
601
752
|
const began = performance.now();
|
|
602
753
|
let endWork = () => {};
|
|
603
754
|
this.#acceptedRequests++;
|
|
604
|
-
const
|
|
605
|
-
|
|
755
|
+
const turn = new Promise<void>((start) => this.#enqueue(op, start));
|
|
756
|
+
const answer = turn.then(async () => {
|
|
757
|
+
const finishWork = this.#work(`running worker ${op}`);
|
|
606
758
|
endWork = () => { if (this.#operationWork.delete(endWork)) finishWork(); };
|
|
607
759
|
this.#operationWork.add(endWork);
|
|
608
|
-
|
|
609
|
-
let result = await this.#wireRequest(request) as PullResult<unknown>;
|
|
610
|
-
while (result?.load === 'continue') {
|
|
611
|
-
this.#loadBoundary = result.phase;
|
|
612
|
-
result = await this.#wireRequest({ op: 'load-next', token: result.token }) as PullResult<unknown>;
|
|
613
|
-
}
|
|
614
|
-
return result?.load === 'done' ? result.value : result;
|
|
760
|
+
return run();
|
|
615
761
|
}).finally(() => {
|
|
616
762
|
this.#acceptedRequests--;
|
|
617
763
|
endWork();
|
|
618
764
|
const totalMs = Math.round(performance.now() - began);
|
|
619
|
-
try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${
|
|
765
|
+
try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${op} totalMs=${totalMs}`); } catch { /* diagnostics cannot poison the queue */ }
|
|
766
|
+
this.#next();
|
|
620
767
|
});
|
|
621
|
-
this.#requestTail = answer.then(() => {}, () => {});
|
|
622
768
|
return answer;
|
|
623
769
|
}
|
|
770
|
+
/** One request to the worker, a load's continuations included. */
|
|
771
|
+
async #wire(request: Request): Promise<unknown> {
|
|
772
|
+
this.#loadBoundary = request.op;
|
|
773
|
+
let result = await this.#wireRequest(request) as PullResult<unknown>;
|
|
774
|
+
while (result?.load === 'continue') {
|
|
775
|
+
this.#loadBoundary = result.phase;
|
|
776
|
+
result = await this.#wireRequest({ op: 'load-next', token: result.token }) as PullResult<unknown>;
|
|
777
|
+
}
|
|
778
|
+
return result?.load === 'done' ? result.value : result;
|
|
779
|
+
}
|
|
624
780
|
|
|
625
781
|
#wireRequest(request: Request): Promise<unknown> {
|
|
626
782
|
if (this.#terminated) return Promise.reject(new Error('The Blender session is terminated'));
|
|
@@ -683,6 +839,13 @@ export class BlenderRuntime {
|
|
|
683
839
|
}
|
|
684
840
|
if (reply.op === 'document-dirty') {
|
|
685
841
|
this.#dirty = reply.dirty;
|
|
842
|
+
try { this.#options.documentDirty?.(reply.dirty); }
|
|
843
|
+
catch { /* an observer cannot change whether the document changed */ }
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (reply.op === 'document-saving') {
|
|
847
|
+
try { this.#options.documentSaving?.({ document: reply.document, note: reply.note }); }
|
|
848
|
+
catch { /* an observer cannot change whether the document was saved */ }
|
|
686
849
|
return;
|
|
687
850
|
}
|
|
688
851
|
if (reply.op === 'document-moved') {
|
|
@@ -698,6 +861,8 @@ export class BlenderRuntime {
|
|
|
698
861
|
return;
|
|
699
862
|
}
|
|
700
863
|
if (reply.op === 'log') {
|
|
864
|
+
// (a heap the browser refused to grow is kept past the aborts it causes, which push it out of the last lines)
|
|
865
|
+
if (reply.text.startsWith(HEAP_GROWTH_REFUSED)) this.#growthRefused ??= reply.text;
|
|
701
866
|
this.#lastLines.push(reply.text);
|
|
702
867
|
if (this.#lastLines.length > 12) this.#lastLines.shift();
|
|
703
868
|
this.#options.log?.(reply.level, reply.text);
|
|
@@ -721,8 +886,8 @@ export class BlenderRuntime {
|
|
|
721
886
|
this.#worker.postMessage({
|
|
722
887
|
op: 'present-result',
|
|
723
888
|
id: reply.id,
|
|
724
|
-
...(answer
|
|
725
|
-
...(
|
|
889
|
+
...(answer.capture === undefined ? {} : { capture: answer.capture }),
|
|
890
|
+
...('held' in answer
|
|
726
891
|
? { held: answer.held }
|
|
727
892
|
: {}),
|
|
728
893
|
} satisfies WorkerRequest);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE ADD-ONS THIS BLENDER SHIPS beside Blender's own, as the files each engine writes into the session's root before
|
|
3
|
+
* Blender starts (`<SESSION_ROOT>/addons/<package>/…`), where `session.py`'s `_enable_addons` enables every package it
|
|
4
|
+
* finds there for the whole session.
|
|
5
|
+
*
|
|
6
|
+
* - `addons/vrm`: saturday06's VRM Add-on for Blender ("VRM format"), the release `addons/vrm.json` names, as it was
|
|
7
|
+
* published. Blender reads its Python and its manifest (`common/version.py` reads `blender_manifest.toml` at import)
|
|
8
|
+
* and its MToon node-group libraries (`common/*.blend`); its documentation and licence texts stay in the package.
|
|
9
|
+
*
|
|
10
|
+
* Text crosses as the module's own source; a `.blend` is bytes, fetched from the URL the bundle gives it.
|
|
11
|
+
*/
|
|
12
|
+
import { SESSION_ROOT } from './blender-engine.mts';
|
|
13
|
+
|
|
14
|
+
const texts = import.meta.glob<string>(['./addons/vrm/**/*.py', './addons/vrm/blender_manifest.toml'], {
|
|
15
|
+
query: '?raw',
|
|
16
|
+
import: 'default',
|
|
17
|
+
eager: true,
|
|
18
|
+
});
|
|
19
|
+
const libraries = import.meta.glob<string>('./addons/vrm/**/*.blend', { query: '?url', import: 'default', eager: true });
|
|
20
|
+
|
|
21
|
+
/** Every add-on file, at its path in the module filesystem. */
|
|
22
|
+
export async function sessionAddonFiles(): Promise<readonly { readonly path: string; readonly bytes: Uint8Array }[]> {
|
|
23
|
+
const at = (key: string): string => `${SESSION_ROOT}/${key.slice('./'.length)}`;
|
|
24
|
+
const utf8 = new TextEncoder();
|
|
25
|
+
return [
|
|
26
|
+
...Object.entries(texts).map(([key, text]) => ({ path: at(key), bytes: utf8.encode(text) })),
|
|
27
|
+
...(await Promise.all(Object.entries(libraries).map(async ([key, url]) => {
|
|
28
|
+
const answer = await fetch(url);
|
|
29
|
+
if (!answer.ok) throw new Error(`The add-on file ${key.slice('./'.length)} could not be read: ${url} answered HTTP ${answer.status}`);
|
|
30
|
+
return { path: at(key), bytes: new Uint8Array(await answer.arrayBuffer()) };
|
|
31
|
+
}))),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
@@ -54,8 +54,9 @@ type Typed =
|
|
|
54
54
|
| Int16Array
|
|
55
55
|
| Uint16Array;
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
57
|
+
/**
|
|
58
|
+
* Column dtype vocabulary matching the C types written by the export door.
|
|
59
|
+
*/
|
|
59
60
|
const READERS: Record<string, (bytes: Uint8Array) => Typed> = {
|
|
60
61
|
f32: (b) => new Float32Array(b.buffer, b.byteOffset, b.byteLength >> 2),
|
|
61
62
|
f64: (b) => new Float64Array(b.buffer, b.byteOffset, b.byteLength >> 3),
|
|
@@ -113,34 +114,14 @@ export function columnsToTypedArrays(arena: Uint8Array, frame: unknown): unknown
|
|
|
113
114
|
|
|
114
115
|
/** A column as the RECORD of it: what it was, how many bytes, and their digest.
|
|
115
116
|
* Never the bytes — see `describeFrame`. */
|
|
116
|
-
|
|
117
|
+
interface ColumnDigest {
|
|
117
118
|
dtype: string;
|
|
118
119
|
length: number;
|
|
119
120
|
/** Lowercase hex SHA-256 of exactly the `length` bytes the descriptor names. */
|
|
120
121
|
sha256: string;
|
|
121
122
|
}
|
|
122
123
|
|
|
123
|
-
/**
|
|
124
|
-
* THE FRAME THIS PRESENT SUBMITTED, as a record that can sit in a JSON file.
|
|
125
|
-
*
|
|
126
|
-
* The presenter is asked, later, whether what it DISPLAYS is what the session
|
|
127
|
-
* SENT (`replay_draw_compare.compare_draws`), and that comparison had no second
|
|
128
|
-
* side: the live document keeps the frame's identity and object table but drops
|
|
129
|
-
* every geometry payload the moment it has built the `BufferGeometry` (850 MB of
|
|
130
|
-
* main-thread heap otherwise — `blender-runtime-view.ts`), and nothing else
|
|
131
|
-
* remembered the frame at all.
|
|
132
|
-
*
|
|
133
|
-
* So the worker records it here, while the arena is still the frame's own: every
|
|
134
|
-
* `{offset, length, dtype, count, stride}` becomes `{dtype, length, sha256}` and
|
|
135
|
-
* everything else — objects, materials, cameras, the world, the counts — is
|
|
136
|
-
* copied as it stands. THE COLUMN BYTES NEVER LEAVE THE TAB; a record of the
|
|
137
|
-
* numbers is not the numbers, and a battery run must not write a scene's meshes
|
|
138
|
-
* to disk a second time.
|
|
139
|
-
*
|
|
140
|
-
* This runs BEFORE `presentToTab` posts, for two reasons that are both fatal
|
|
141
|
-
* otherwise: the buffers are TRANSFERRED (detached the instant they are posted),
|
|
142
|
-
* and the arena is overwritten by the next `export_frame`.
|
|
143
|
-
*/
|
|
124
|
+
/** Record the submitted frame’s metadata and hash each arena column before presentToTab transfers its buffers. Geometry bytes stay in the tab; the record identifies the data that was submitted. */
|
|
144
125
|
export async function describeFrame(arena: Uint8Array, frame: unknown): Promise<unknown> {
|
|
145
126
|
const walk = async (value: unknown, key: string): Promise<unknown> => {
|
|
146
127
|
if (isColumnDescriptor(value)) {
|