@volter/blender-engine 0.1.147 → 0.1.149

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.
Files changed (245) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +4 -11
  3. package/browser/addons/vrm/LICENSE_(OPTION1)_MIT.txt +22 -0
  4. package/browser/addons/vrm/LICENSE_(OPTION2)_GPL-3_0.txt +674 -0
  5. package/browser/addons/vrm/LICENSE_MAIN.txt +1 -0
  6. package/browser/addons/vrm/README.txt +3 -0
  7. package/browser/addons/vrm/__init__.py +41 -0
  8. package/browser/addons/vrm/blender_manifest.toml +16 -0
  9. package/browser/addons/vrm/common/__init__.py +1 -0
  10. package/browser/addons/vrm/common/animation.py +58 -0
  11. package/browser/addons/vrm/common/blender_manifest.py +82 -0
  12. package/browser/addons/vrm/common/char.py +16 -0
  13. package/browser/addons/vrm/common/convert.py +287 -0
  14. package/browser/addons/vrm/common/convert_any.py +48 -0
  15. package/browser/addons/vrm/common/debug.py +40 -0
  16. package/browser/addons/vrm/common/deep.py +162 -0
  17. package/browser/addons/vrm/common/error_dialog.py +274 -0
  18. package/browser/addons/vrm/common/fs.py +71 -0
  19. package/browser/addons/vrm/common/gl.py +20 -0
  20. package/browser/addons/vrm/common/gltf.py +1149 -0
  21. package/browser/addons/vrm/common/human_bone_mapper/__init__.py +1 -0
  22. package/browser/addons/vrm/common/human_bone_mapper/biped_mapping.py +84 -0
  23. package/browser/addons/vrm/common/human_bone_mapper/cats_blender_plugin_fix_model_mapping.py +63 -0
  24. package/browser/addons/vrm/common/human_bone_mapper/human_bone_mapper.py +203 -0
  25. package/browser/addons/vrm/common/human_bone_mapper/microsoft_rocketbox_mapping.py +69 -0
  26. package/browser/addons/vrm/common/human_bone_mapper/mixamo_mapping.py +62 -0
  27. package/browser/addons/vrm/common/human_bone_mapper/mmd_mapping.py +115 -0
  28. package/browser/addons/vrm/common/human_bone_mapper/mpfb_mapping.py +120 -0
  29. package/browser/addons/vrm/common/human_bone_mapper/ready_player_me_mapping.py +65 -0
  30. package/browser/addons/vrm/common/human_bone_mapper/reallusion_mapping.py +94 -0
  31. package/browser/addons/vrm/common/human_bone_mapper/rigify_meta_rig_mapping.py +65 -0
  32. package/browser/addons/vrm/common/human_bone_mapper/structure_based_mapping.py +891 -0
  33. package/browser/addons/vrm/common/human_bone_mapper/unreal_mapping.py +62 -0
  34. package/browser/addons/vrm/common/human_bone_mapper/vrm_addon_mapping.py +112 -0
  35. package/browser/addons/vrm/common/human_bone_mapper/vroid_mapping.py +89 -0
  36. package/browser/addons/vrm/common/legacy_gltf.py +16 -0
  37. package/browser/addons/vrm/common/logger.py +84 -0
  38. package/browser/addons/vrm/common/mtoon0.blend +0 -0
  39. package/browser/addons/vrm/common/mtoon1.blend +0 -0
  40. package/browser/addons/vrm/common/mtoon1_outline.blend +0 -0
  41. package/browser/addons/vrm/common/mtoon_auto_setup.blend +0 -0
  42. package/browser/addons/vrm/common/mtoon_unversioned.py +62 -0
  43. package/browser/addons/vrm/common/ops/__init__.py +10 -0
  44. package/browser/addons/vrm/common/ops/export_scene.py +67 -0
  45. package/browser/addons/vrm/common/ops/icyp.py +53 -0
  46. package/browser/addons/vrm/common/ops/import_scene.py +57 -0
  47. package/browser/addons/vrm/common/ops/vrm.py +2380 -0
  48. package/browser/addons/vrm/common/ops/wm.py +167 -0
  49. package/browser/addons/vrm/common/preferences.py +362 -0
  50. package/browser/addons/vrm/common/progress.py +87 -0
  51. package/browser/addons/vrm/common/rotation.py +89 -0
  52. package/browser/addons/vrm/common/safe_removal.py +46 -0
  53. package/browser/addons/vrm/common/scene_watcher.py +230 -0
  54. package/browser/addons/vrm/common/shader.py +2243 -0
  55. package/browser/addons/vrm/common/shape_key_mapper/__init__.py +1 -0
  56. package/browser/addons/vrm/common/shape_key_mapper/arkit_mapping.py +168 -0
  57. package/browser/addons/vrm/common/shape_key_mapper/mmd_mapping.py +62 -0
  58. package/browser/addons/vrm/common/shape_key_mapper/ready_player_me_mapping.py +52 -0
  59. package/browser/addons/vrm/common/shape_key_mapper/vrchat_mapping.py +17 -0
  60. package/browser/addons/vrm/common/third_party_user_extension.py +214 -0
  61. package/browser/addons/vrm/common/transparent_z_write.blend +0 -0
  62. package/browser/addons/vrm/common/version.py +161 -0
  63. package/browser/addons/vrm/common/vrm0/__init__.py +1 -0
  64. package/browser/addons/vrm/common/vrm0/human_bone.py +662 -0
  65. package/browser/addons/vrm/common/vrm0/material_property.py +346 -0
  66. package/browser/addons/vrm/common/vrm1/__init__.py +1 -0
  67. package/browser/addons/vrm/common/vrm1/expression_preset.py +207 -0
  68. package/browser/addons/vrm/common/vrm1/human_bone.py +822 -0
  69. package/browser/addons/vrm/common/workspace.py +227 -0
  70. package/browser/addons/vrm/common/writable_context.py +77 -0
  71. package/browser/addons/vrm/documentation/en-us/animation/index.md +63 -0
  72. package/browser/addons/vrm/documentation/en-us/create-humanoid-vrm-from-scratch/index.md +131 -0
  73. package/browser/addons/vrm/documentation/en-us/create-simple-vrm-from-scratch/index.md +30 -0
  74. package/browser/addons/vrm/documentation/en-us/development/index.md +136 -0
  75. package/browser/addons/vrm/documentation/en-us/index.md +41 -0
  76. package/browser/addons/vrm/documentation/en-us/installation/index.md +152 -0
  77. package/browser/addons/vrm/documentation/en-us/material-mtoon/index.md +122 -0
  78. package/browser/addons/vrm/documentation/en-us/material-pbr/index.md +94 -0
  79. package/browser/addons/vrm/documentation/en-us/scripting-api/index.md +391 -0
  80. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrm/index.md +57 -0
  81. package/browser/addons/vrm/documentation/en-us/ui/export_scene.vrma/index.md +17 -0
  82. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrm/index.md +61 -0
  83. package/browser/addons/vrm/documentation/en-us/ui/import_scene.vrma/index.md +13 -0
  84. package/browser/addons/vrm/documentation/ja-jp/animation/index.md +57 -0
  85. package/browser/addons/vrm/documentation/ja-jp/create-humanoid-vrm-from-scratch/index.md +129 -0
  86. package/browser/addons/vrm/documentation/ja-jp/create-simple-vrm-from-scratch/index.md +29 -0
  87. package/browser/addons/vrm/documentation/ja-jp/development/index.md +137 -0
  88. package/browser/addons/vrm/documentation/ja-jp/index.md +37 -0
  89. package/browser/addons/vrm/documentation/ja-jp/installation/index.md +150 -0
  90. package/browser/addons/vrm/documentation/ja-jp/material-mtoon/index.md +113 -0
  91. package/browser/addons/vrm/documentation/ja-jp/material-pbr/index.md +90 -0
  92. package/browser/addons/vrm/documentation/ja-jp/scripting-api/index.md +389 -0
  93. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrm/index.md +55 -0
  94. package/browser/addons/vrm/documentation/ja-jp/ui/export_scene.vrma/index.md +19 -0
  95. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrm/index.md +56 -0
  96. package/browser/addons/vrm/documentation/ja-jp/ui/import_scene.vrma/index.md +14 -0
  97. package/browser/addons/vrm/editor/__init__.py +1 -0
  98. package/browser/addons/vrm/editor/extension.py +425 -0
  99. package/browser/addons/vrm/editor/extension_accessor.py +75 -0
  100. package/browser/addons/vrm/editor/handler.py +84 -0
  101. package/browser/addons/vrm/editor/khr_character/__init__.py +1 -0
  102. package/browser/addons/vrm/editor/khr_character/panel.py +69 -0
  103. package/browser/addons/vrm/editor/khr_character/property_group.py +27 -0
  104. package/browser/addons/vrm/editor/khr_xmp_json_ld/__init__.py +1 -0
  105. package/browser/addons/vrm/editor/khr_xmp_json_ld/ops.py +464 -0
  106. package/browser/addons/vrm/editor/khr_xmp_json_ld/panel.py +136 -0
  107. package/browser/addons/vrm/editor/khr_xmp_json_ld/property_group.py +83 -0
  108. package/browser/addons/vrm/editor/khr_xmp_json_ld/ui_list.py +198 -0
  109. package/browser/addons/vrm/editor/make_armature.py +709 -0
  110. package/browser/addons/vrm/editor/menu.py +147 -0
  111. package/browser/addons/vrm/editor/migration.py +297 -0
  112. package/browser/addons/vrm/editor/mtoon1/__init__.py +1 -0
  113. package/browser/addons/vrm/editor/mtoon1/handler.py +20 -0
  114. package/browser/addons/vrm/editor/mtoon1/migration.py +536 -0
  115. package/browser/addons/vrm/editor/mtoon1/ops.py +304 -0
  116. package/browser/addons/vrm/editor/mtoon1/panel.py +442 -0
  117. package/browser/addons/vrm/editor/mtoon1/property_group.py +4977 -0
  118. package/browser/addons/vrm/editor/mtoon1/scene_watcher.py +381 -0
  119. package/browser/addons/vrm/editor/node_constraint1/__init__.py +1 -0
  120. package/browser/addons/vrm/editor/node_constraint1/panel.py +317 -0
  121. package/browser/addons/vrm/editor/node_constraint1/property_group.py +25 -0
  122. package/browser/addons/vrm/editor/ops.py +471 -0
  123. package/browser/addons/vrm/editor/panel.py +315 -0
  124. package/browser/addons/vrm/editor/property_group.py +888 -0
  125. package/browser/addons/vrm/editor/search.py +682 -0
  126. package/browser/addons/vrm/editor/spring_bone1/__init__.py +1 -0
  127. package/browser/addons/vrm/editor/spring_bone1/handler.py +994 -0
  128. package/browser/addons/vrm/editor/spring_bone1/menu.py +246 -0
  129. package/browser/addons/vrm/editor/spring_bone1/migration.py +86 -0
  130. package/browser/addons/vrm/editor/spring_bone1/ops.py +1679 -0
  131. package/browser/addons/vrm/editor/spring_bone1/panel.py +605 -0
  132. package/browser/addons/vrm/editor/spring_bone1/property_group.py +1457 -0
  133. package/browser/addons/vrm/editor/spring_bone1/ui_list.py +408 -0
  134. package/browser/addons/vrm/editor/subscription.py +123 -0
  135. package/browser/addons/vrm/editor/t_pose.py +713 -0
  136. package/browser/addons/vrm/editor/validation.py +1271 -0
  137. package/browser/addons/vrm/editor/vrm0/__init__.py +1 -0
  138. package/browser/addons/vrm/editor/vrm0/gizmo_group.py +63 -0
  139. package/browser/addons/vrm/editor/vrm0/handler.py +23 -0
  140. package/browser/addons/vrm/editor/vrm0/menu.py +145 -0
  141. package/browser/addons/vrm/editor/vrm0/migration.py +761 -0
  142. package/browser/addons/vrm/editor/vrm0/ops.py +2145 -0
  143. package/browser/addons/vrm/editor/vrm0/panel.py +1333 -0
  144. package/browser/addons/vrm/editor/vrm0/property_group.py +1818 -0
  145. package/browser/addons/vrm/editor/vrm0/ui_list.py +688 -0
  146. package/browser/addons/vrm/editor/vrm1/__init__.py +1 -0
  147. package/browser/addons/vrm/editor/vrm1/handler.py +28 -0
  148. package/browser/addons/vrm/editor/vrm1/menu.py +76 -0
  149. package/browser/addons/vrm/editor/vrm1/migration.py +321 -0
  150. package/browser/addons/vrm/editor/vrm1/ops.py +2679 -0
  151. package/browser/addons/vrm/editor/vrm1/panel.py +1243 -0
  152. package/browser/addons/vrm/editor/vrm1/property_group.py +2087 -0
  153. package/browser/addons/vrm/editor/vrm1/scene_watcher.py +90 -0
  154. package/browser/addons/vrm/editor/vrm1/ui_list.py +533 -0
  155. package/browser/addons/vrm/exporter/__init__.py +1 -0
  156. package/browser/addons/vrm/exporter/abstract_base_vrm_exporter.py +628 -0
  157. package/browser/addons/vrm/exporter/export_scene.py +930 -0
  158. package/browser/addons/vrm/exporter/gltf2_export_user_extension.py +45 -0
  159. package/browser/addons/vrm/exporter/khr_character_exporter.py +34 -0
  160. package/browser/addons/vrm/exporter/vrm0_exporter.py +4020 -0
  161. package/browser/addons/vrm/exporter/vrm1_exporter.py +3682 -0
  162. package/browser/addons/vrm/exporter/vrm_animation_exporter.py +1035 -0
  163. package/browser/addons/vrm/external/__init__.py +1 -0
  164. package/browser/addons/vrm/external/auto_rig_pro_support.py +3 -0
  165. package/browser/addons/vrm/external/io_scene_gltf2_support.py +418 -0
  166. package/browser/addons/vrm/external/rigify_support.py +1 -0
  167. package/browser/addons/vrm/importer/__init__.py +1 -0
  168. package/browser/addons/vrm/importer/abstract_base_vrm_importer.py +1548 -0
  169. package/browser/addons/vrm/importer/file_handler.py +34 -0
  170. package/browser/addons/vrm/importer/gltf2_import_user_extension.py +268 -0
  171. package/browser/addons/vrm/importer/import_scene.py +646 -0
  172. package/browser/addons/vrm/importer/license_validation.py +198 -0
  173. package/browser/addons/vrm/importer/vrm0_importer.py +1723 -0
  174. package/browser/addons/vrm/importer/vrm1_importer.py +2014 -0
  175. package/browser/addons/vrm/importer/vrm_animation_importer.py +763 -0
  176. package/browser/addons/vrm/importer/vrm_diff.py +316 -0
  177. package/browser/addons/vrm/locale/__init__.py +1 -0
  178. package/browser/addons/vrm/locale/ja_jp.py +826 -0
  179. package/browser/addons/vrm/locale/ruff.toml +8 -0
  180. package/browser/addons/vrm/locale/translation_dictionary.py +10 -0
  181. package/browser/addons/vrm/locale/zh_hans.py +928 -0
  182. package/browser/addons/vrm/registration.py +670 -0
  183. package/browser/addons/vrm.json +18 -0
  184. package/browser/blender-emscripten-engine.mts +60 -128
  185. package/browser/blender-engine.mts +20 -186
  186. package/browser/blender-wali-engine.mts +35 -81
  187. package/browser/describe-thrown.mts +3 -6
  188. package/browser/document-chunks.mts +4 -16
  189. package/browser/gltf_export.py +3161 -0
  190. package/browser/heap-growth.mts +23 -0
  191. package/browser/index.ts +2 -0
  192. package/browser/protocol.ts +119 -35
  193. package/browser/pull-job.mts +2 -10
  194. package/browser/rna.ts +190 -73
  195. package/browser/runtime.ts +197 -82
  196. package/browser/session-addons.mts +33 -0
  197. package/browser/session-frame.mts +5 -24
  198. package/browser/session.py +1338 -1427
  199. package/browser/three/attach-presenter.ts +11 -39
  200. package/browser/three/blender-base64.ts +1 -36
  201. package/browser/three/blender-corner-normals.ts +1 -23
  202. package/browser/three/blender-display-shaders.json +1 -1
  203. package/browser/three/blender-display-shaders.lut +0 -0
  204. package/browser/three/blender-display-transform.ts +2 -1
  205. package/browser/three/blender-frame-columns.ts +5 -13
  206. package/browser/three/blender-graph-material.ts +18 -3
  207. package/browser/three/blender-node-graph.ts +10 -34
  208. package/browser/three/blender-physical-material.ts +2 -31
  209. package/browser/three/blender-runtime-armature.ts +7 -60
  210. package/browser/three/blender-runtime-frame.ts +9 -23
  211. package/browser/three/blender-runtime-geometry.ts +22 -26
  212. package/browser/three/blender-runtime-lighting.ts +48 -178
  213. package/browser/three/blender-runtime-shadows.ts +187 -1
  214. package/browser/three/blender-runtime-skeleton.ts +53 -1
  215. package/browser/three/blender-runtime-view.ts +505 -283
  216. package/browser/three/blender-runtime-weights.ts +9 -72
  217. package/browser/three/blender-sky.ts +13 -33
  218. package/browser/three/blender-triangulate.ts +7 -35
  219. package/browser/three/blender-viewport-sampling.ts +235 -0
  220. package/browser/three/blender-workbench-material.ts +25 -29
  221. package/browser/three/blender-workbench-outline.ts +199 -0
  222. package/browser/three/blender-world-volume.ts +62 -10
  223. package/browser/three/presenter.ts +10 -26
  224. package/browser/three/release.ts +5 -21
  225. package/browser/three/sky-precompute-worker.ts +1 -18
  226. package/browser/three/sky-worker.ts +5 -23
  227. package/browser/three/world-field-sampler.ts +12 -26
  228. package/browser/volter_three.py +9 -105
  229. package/browser/worker.ts +156 -151
  230. package/package.json +5 -2
  231. package/wasm/BUNDLE.json +17 -17
  232. package/wasm/DEPENDENCY-LICENSES.txt +14 -0
  233. package/wasm/blender_browser.data.br +0 -0
  234. package/wasm/blender_browser.js +1 -1
  235. package/wasm/blender_browser.wasm.br +0 -0
  236. package/browser/frame-transfer.mts +0 -19
  237. package/browser/three/agx-base-srgb.lut +0 -0
  238. package/browser/three/agx-look-medium-high-contrast.lut +0 -0
  239. package/browser/three/agx-look-punchy.lut +0 -0
  240. package/browser/three/blender-agx.ts +0 -235
  241. package/browser/three/blender-display-lut.ts +0 -145
  242. package/browser/three/blender-filmic.ts +0 -49
  243. package/browser/three/blender-standard.ts +0 -68
  244. package/browser/three/blender-world-visibility.ts +0 -278
  245. package/browser/three/filmic-srgb.lut +0 -0
@@ -1,13 +1,14 @@
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 the
5
- * four tool calls, and hands every presented frame to whoever displays it.
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.
6
8
  * One instance per editor tab; the session and its model die with the worker.
7
9
  */
8
10
  import type {
9
11
  CaptureRequest,
10
- FileEntry,
11
12
  RuntimeStart,
12
13
  WorkerReply,
13
14
  WorkerRequest,
@@ -15,9 +16,16 @@ import type {
15
16
  NativePreviewRequest,
16
17
  NativePreviewResult,
17
18
  FileListSnapshot,
19
+ GltfExportAnswer,
20
+ GltfExportKind,
21
+ GltfExportPrevious,
22
+ ScriptRunAnswer,
23
+ BlenderAssetLibrary,
18
24
  } from './protocol';
19
25
  import type {
20
26
  BlenderActionClip,
27
+ BlenderSceneMovie,
28
+ BlenderSoundScene,
21
29
  BlenderNodeTree,
22
30
  BlenderOutlinerTree,
23
31
  BlenderOutlinerWrite,
@@ -30,6 +38,17 @@ import type {
30
38
  type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
31
39
  type Request = DistributiveOmit<WorkerRequest, 'id'>;
32
40
 
41
+
42
+ /** A script every save of a document runs in the save's own turn (`BlenderRuntime.saveDocument`), as the caller names
43
+ * it for the document saved: null for none. */
44
+ export type SaveScript = (document: string) => Promise<{ readonly source: string; readonly args: readonly string[] }> | null;
45
+
46
+ /** A save that landed: the document it wrote, and what its script did on the scene it wrote (null where none ran). */
47
+ export interface SavedDocument {
48
+ readonly document: string;
49
+ readonly script: ScriptRunAnswer | null;
50
+ }
51
+
33
52
  export interface BlenderRuntimeOptions {
34
53
  /** Stage one verified mesh/image without replacing the displayed frame. */
35
54
  stage?(part: { session: string; revision: number; evaluation?: 'render'; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): Promise<void> | void;
@@ -56,17 +75,14 @@ export interface BlenderRuntimeOptions {
56
75
  * names the new file and before the call that moved it answers. An observer: a throw here
57
76
  * changes nothing. */
58
77
  documentMoved?(moved: { readonly from: string; readonly document: string }): void;
78
+ /** The worker's save refusal or its recovery, for the named project document. */
79
+ documentSaving?(state: { readonly document: string; readonly note: string | null }): void;
80
+ /** The document changed (`true`, at every change) or became its file again (`false`). An
81
+ * observer: a throw here changes nothing. */
82
+ documentDirty?(dirty: boolean): void;
59
83
  }
60
84
 
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
- */
85
+ /** 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
86
  export interface PresentAnswer {
71
87
  capture?: unknown;
72
88
  held?: { session: string; revision: number } | null;
@@ -94,24 +110,7 @@ export interface ScreenshotView {
94
110
  target?: number[];
95
111
  }
96
112
 
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
- */
113
+ /** 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
114
  export interface BlenderCallMetrics {
116
115
  /** Age of the OLDEST outstanding call, or null when the worker is idle.
117
116
  * This is the field that has a number during the wedge — the counters
@@ -124,10 +123,10 @@ export interface BlenderCallMetrics {
124
123
  * exactly as long as it keeps running. */
125
124
  readonly maxCallMs: number | null;
126
125
  /** Operation and load boundary owning the longest request; no payload data. */
127
- readonly maxCallLabel?: string | null;
126
+ readonly maxCallLabel: string | null;
128
127
  /** Current cooperative phase and count of settled wire requests; sampled, not pushed per unit. */
129
- readonly currentPhase?: string | null;
130
- readonly completedCalls?: number;
128
+ readonly currentPhase: string | null;
129
+ readonly completedCalls: number;
131
130
  /** Calls whose duration passed 5s, counting outstanding ones already past it. */
132
131
  readonly callsOver5s: number;
133
132
  /** The same at 30s — a call this side of it is slow, past it is a wedge. */
@@ -153,12 +152,22 @@ export interface BlenderCallMetrics {
153
152
  * `blender_web_headless.cmake`). JavaScript cannot read a memory's maximum back. */
154
153
  const ENGINE_MEMORY_CEILING_MB = 4096;
155
154
 
155
+ /**
156
+ * Requests in OVERTAKES may overtake queued OVERTAKEN document reads. Export and read-file
157
+ * operations keep their order relative to edits; skin, movie, Properties, Outliner, node and UV
158
+ * inspections can yield their queue position.
159
+ */
160
+ const OVERTAKES: ReadonlySet<string> = new Set(['gltf-export', 'read-file']);
161
+ const OVERTAKEN: ReadonlySet<string> = new Set(['action-clip', 'scene-movie', 'rna', 'rna-context', 'outliner', 'node-tree', 'uv-layout']);
162
+
156
163
  export class BlenderRuntime {
157
164
  readonly #worker: Worker;
158
165
  readonly #options: BlenderRuntimeOptions;
159
166
  /** Blender's own last output lines, which are what says why it died when it dies: they reach
160
167
  * the page as ordinary output, so the failure below carries them. */
161
168
  readonly #lastLines: string[] = [];
169
+ /** The line a thread printed when the browser refused to grow the heap, kept for the failure it causes. */
170
+ #growthRefused: string | null = null;
162
171
  readonly #pending = new Map<
163
172
  number,
164
173
  { resolve: (value: unknown) => void; reject: (error: Error) => void }
@@ -166,6 +175,8 @@ export class BlenderRuntime {
166
175
  #nextId = 0;
167
176
  #started: Promise<RuntimeStart> | null = null;
168
177
  #stopping: Promise<void> | null = null;
178
+ /** The script runs this runtime has made, each in a directory of its own. */
179
+ #scriptRuns = 0;
169
180
  #terminated = false;
170
181
  #dirty = false;
171
182
  readonly #beforeUnload = (event: BeforeUnloadEvent): void => {
@@ -198,33 +209,28 @@ export class BlenderRuntime {
198
209
  globalThis.addEventListener?.('beforeunload', this.#beforeUnload);
199
210
  this.#worker.onmessage = (event: MessageEvent<WorkerReply>) => void this.#receive(event.data);
200
211
  this.#worker.onerror = (event) => {
201
- // A module worker that fails to LOAD reports an ErrorEvent with an empty
202
- // message, so `${event.message}` alone said "undefined" and named
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.
212
+ // Worker errors include the thrown stack or message and the source location.
213
+ // Failed module imports may provide only the file and line.
206
214
  const thrown = event.error as Error | undefined;
207
215
  const where = event.filename ? ` at ${event.filename}:${event.lineno}:${event.colno}` : '';
208
216
  const said = thrown?.stack ?? thrown?.message ?? event.message;
209
- // AND BLENDER'S OWN LAST WORDS: a thread that dies throws whatever it threw, often not an
210
- // Error ("Uncaught [object Object]"), while the reason (an allocation that failed, the file
211
- // it was reading) is the last thing Blender printed.
217
+ // Include Blender's retained output alongside the worker error.
212
218
  const last = this.#lastLines.length > 0 ? `\nBlender's last output:\n${this.#lastLines.join('\n')}` : '';
213
219
  // OUT OF MEMORY IS SAID AS SUCH, from the heap the worker reported as the thread died.
214
220
  const heapMB = this.#wasmBytes === null ? null : Math.round(this.#wasmBytes / 1048576);
215
- const exhausted =
216
- heapMB !== null && heapMB >= ENGINE_MEMORY_CEILING_MB - 16
221
+ const refused = this.#growthRefused;
222
+ const exhausted = refused !== null
223
+ ? `Blender ran out of memory: the browser would not give its heap more (${refused.slice(HEAP_GROWTH_REFUSED.length + 2)}), ` +
224
+ "below the in-tab engine's ceiling, which happens when the machine itself is short of memory. " +
225
+ 'Free memory (other editors, tabs, builds) and run the call again. '
226
+ : heapMB !== null && heapMB >= ENGINE_MEMORY_CEILING_MB - 16
217
227
  ? `Blender ran out of memory: its heap reached ${heapMB} MB of the in-tab engine's ` +
218
228
  `${ENGINE_MEMORY_CEILING_MB} MB, and this scene needs more. `
219
229
  : '';
220
230
  const error = new Error(
221
231
  `${exhausted}Blender worker failed: ${said || 'the worker script did not load'}${where}${last}`,
222
232
  );
223
- // The page console is the editor's ledger (`installEditorConsoleReporting`
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).
233
+ // Report initialization failures to the page console, which the editor captures into its session ledger.
228
234
  console.error(error.message);
229
235
  for (const id of [...this.#pending.keys()]) this.#settled(id);
230
236
  for (const pending of this.#pending.values()) pending.reject(error);
@@ -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,38 @@ 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. */
514
+ async saveDocument(after?: SaveScript): Promise<SavedDocument | null> {
515
+ await this.#ready();
516
+ return this.#inTurn('flush-document', false, () => this.#save(after));
517
+ }
518
+
519
+ /** The document's bytes as a save would write them, carried nowhere. */
520
+ async snapshotDocument(): Promise<Uint8Array> {
521
+ await this.#ready();
522
+ return (await this.#request({ op: 'snapshot-document' })) as Uint8Array;
523
+ }
524
+
525
+ /** Reopen the document from its project file, discarding its changes. */
526
+ async revertDocument(): Promise<void> {
527
+ await this.#ready();
528
+ await this.#request({ op: 'revert-document' });
529
+ }
530
+
531
+ /** Reopen the document as `bytes`, its unsaved state. */
532
+ async restoreDocument(bytes: Uint8Array): Promise<void> {
533
+ await this.#ready();
534
+ await this.#request({ op: 'restore-document', bytes });
535
+ }
536
+
475
537
  async readFile(path: string): Promise<Uint8Array> {
476
538
  await this.#ready();
477
539
  return (await this.#request({ op: 'read-file', path })) as Uint8Array;
@@ -482,11 +544,6 @@ export class BlenderRuntime {
482
544
  await this.#request({ op: 'write-file', path, bytes });
483
545
  }
484
546
 
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
547
  /** Files and their native document identity, without executing Python or
491
548
  * exporting/saving the model. The worker serializes this with authoring. */
492
549
  async listFilesSnapshot(path: string): Promise<FileListSnapshot> {
@@ -501,15 +558,18 @@ export class BlenderRuntime {
501
558
  }
502
559
 
503
560
  /** 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
- stop(): Promise<void> {
561
+ * copy. A failed save keeps the worker alive so the caller can retry. With `retire`, the stop's save is the caller's
562
+ * too: it runs `retire.after`'s script in its turn, as every save does (`saveDocument`), and `landed` hears it. */
563
+ stop(retire?: { readonly after?: SaveScript; readonly landed: (saved: SavedDocument) => void }): Promise<void> {
506
564
  if (this.#terminated) return Promise.resolve();
507
565
  if (this.#stopping) return this.#stopping;
508
566
  this.#stopping = (async () => {
509
567
  // A start that failed opened no document, so there is nothing to flush: its stale
510
568
  // rejection must not refuse every later close.
511
569
  const started = this.#started ? await this.#started.then(() => true, () => false) : false;
512
- if (started) await this.#request({ op: 'flush-document' }, true);
570
+ // (one save, queued behind every request accepted before the stop; none is taken after it)
571
+ const saved = started ? await this.#inTurn('flush-document', true, () => this.#save(retire?.after)) : null;
572
+ if (saved !== null) retire?.landed(saved);
513
573
  this.terminate();
514
574
  })().catch(error => {
515
575
  this.#stopping = null;
@@ -518,6 +578,23 @@ export class BlenderRuntime {
518
578
  return this.#stopping;
519
579
  }
520
580
 
581
+ /** The save, and its script on the scene it wrote, inside one turn. */
582
+ async #save(after: SaveScript | undefined): Promise<SavedDocument | null> {
583
+ const { saved } = (await this.#wire({ op: 'flush-document' })) as { saved: boolean };
584
+ const document = this.#document;
585
+ if (!saved || document === null) return null;
586
+ const named = after?.(document) ?? null;
587
+ if (named === null) return { document, script: null };
588
+ let script: { readonly source: string; readonly args: readonly string[] };
589
+ try {
590
+ script = await named;
591
+ } catch (error) {
592
+ return { document, script: { output: '', error: `the save's script could not be read: ${describeThrown(error)}`, files: [], unstaged: [] } };
593
+ }
594
+ const dir = `/tmp/volter-script-run/${++this.#scriptRuns}`;
595
+ return { document, script: (await this.#wire({ op: 'run-script', source: script.source, args: script.args, dir })) as ScriptRunAnswer };
596
+ }
597
+
521
598
  /** Forced teardown for a lost session; explicit user stops must use stop(). */
522
599
  terminate(): void {
523
600
  this.#discardStagedFrame();
@@ -546,7 +623,8 @@ export class BlenderRuntime {
546
623
  * displaying the frame is main-thread time, which is the long-task observer's
547
624
  * subject (the host's `worker-call-metrics.ts`).
548
625
  */
549
- metrics(now: number = performance.now()): BlenderCallMetrics {
626
+ metrics(): BlenderCallMetrics {
627
+ const now = performance.now();
550
628
  let oldest: number | null = null;
551
629
  let max = this.#maxCompletedMs;
552
630
  let maxLabel = this.#maxCompletedLabel;
@@ -594,33 +672,61 @@ export class BlenderRuntime {
594
672
 
595
673
  #completedCalls = 0;
596
674
  #acceptedRequests = 0;
597
- #requestTail: Promise<void> = Promise.resolve();
598
- #request(request: Request, shutdown = false): Promise<unknown> {
675
+ /**
676
+ * Process requests in queue order. gltf-export and read-file may overtake queued document reads
677
+ * in OVERTAKEN, while preserving the order of edits and other operations.
678
+ */
679
+ #waiting: { readonly op: string; readonly start: () => void }[] = [];
680
+ #busy = false;
681
+ #enqueue(op: string, start: () => void): void {
682
+ const item = { op, start };
683
+ if (OVERTAKES.has(op)) {
684
+ let at = this.#waiting.length;
685
+ while (at > 0 && OVERTAKEN.has(this.#waiting[at - 1]!.op)) at--;
686
+ this.#waiting.splice(at, 0, item);
687
+ } else this.#waiting.push(item);
688
+ if (!this.#busy) this.#next();
689
+ }
690
+ #next(): void {
691
+ const item = this.#waiting.shift();
692
+ this.#busy = item !== undefined;
693
+ item?.start();
694
+ }
695
+ #request(request: Request): Promise<unknown> {
696
+ return this.#inTurn(request.op, false, () => this.#wire(request));
697
+ }
698
+ /** One turn of the queue: `run` alone talks to the worker until it is done, whatever it sends. */
699
+ #inTurn<T>(op: string, shutdown: boolean, run: () => Promise<T>): Promise<T> {
599
700
  if (this.#terminated || (this.#stopping && !shutdown))
600
701
  return Promise.reject(new Error('The Blender session is stopping or terminated'));
601
702
  const began = performance.now();
602
703
  let endWork = () => {};
603
704
  this.#acceptedRequests++;
604
- const answer = this.#requestTail.then(async () => {
605
- const finishWork = this.#work(`running worker ${request.op}`);
705
+ const turn = new Promise<void>((start) => this.#enqueue(op, start));
706
+ const answer = turn.then(async () => {
707
+ const finishWork = this.#work(`running worker ${op}`);
606
708
  endWork = () => { if (this.#operationWork.delete(endWork)) finishWork(); };
607
709
  this.#operationWork.add(endWork);
608
- this.#loadBoundary = request.op;
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;
710
+ return run();
615
711
  }).finally(() => {
616
712
  this.#acceptedRequests--;
617
713
  endWork();
618
714
  const totalMs = Math.round(performance.now() - began);
619
- try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${request.op} totalMs=${totalMs}`); } catch { /* diagnostics cannot poison the queue */ }
715
+ try { if (totalMs >= 1000) this.#options.log?.('log', `@@VOLTER-WORK op=${op} totalMs=${totalMs}`); } catch { /* diagnostics cannot poison the queue */ }
716
+ this.#next();
620
717
  });
621
- this.#requestTail = answer.then(() => {}, () => {});
622
718
  return answer;
623
719
  }
720
+ /** One request to the worker, a load's continuations included. */
721
+ async #wire(request: Request): Promise<unknown> {
722
+ this.#loadBoundary = request.op;
723
+ let result = await this.#wireRequest(request) as PullResult<unknown>;
724
+ while (result?.load === 'continue') {
725
+ this.#loadBoundary = result.phase;
726
+ result = await this.#wireRequest({ op: 'load-next', token: result.token }) as PullResult<unknown>;
727
+ }
728
+ return result?.load === 'done' ? result.value : result;
729
+ }
624
730
 
625
731
  #wireRequest(request: Request): Promise<unknown> {
626
732
  if (this.#terminated) return Promise.reject(new Error('The Blender session is terminated'));
@@ -683,6 +789,13 @@ export class BlenderRuntime {
683
789
  }
684
790
  if (reply.op === 'document-dirty') {
685
791
  this.#dirty = reply.dirty;
792
+ try { this.#options.documentDirty?.(reply.dirty); }
793
+ catch { /* an observer cannot change whether the document changed */ }
794
+ return;
795
+ }
796
+ if (reply.op === 'document-saving') {
797
+ try { this.#options.documentSaving?.({ document: reply.document, note: reply.note }); }
798
+ catch { /* an observer cannot change whether the document was saved */ }
686
799
  return;
687
800
  }
688
801
  if (reply.op === 'document-moved') {
@@ -698,6 +811,8 @@ export class BlenderRuntime {
698
811
  return;
699
812
  }
700
813
  if (reply.op === 'log') {
814
+ // (a heap the browser refused to grow is kept past the aborts it causes, which push it out of the last lines)
815
+ if (reply.text.startsWith(HEAP_GROWTH_REFUSED)) this.#growthRefused ??= reply.text;
701
816
  this.#lastLines.push(reply.text);
702
817
  if (this.#lastLines.length > 12) this.#lastLines.shift();
703
818
  this.#options.log?.(reply.level, reply.text);
@@ -721,8 +836,8 @@ export class BlenderRuntime {
721
836
  this.#worker.postMessage({
722
837
  op: 'present-result',
723
838
  id: reply.id,
724
- ...(answer?.capture === undefined ? {} : { capture: answer.capture }),
725
- ...(answer !== null && answer !== undefined && 'held' in answer
839
+ ...(answer.capture === undefined ? {} : { capture: answer.capture }),
840
+ ...('held' in answer
726
841
  ? { held: answer.held }
727
842
  : {}),
728
843
  } 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
- /** The door's own dtype vocabulary -- the C type each column was written from,
58
- * not numpy's codes: the Python exporter that spoke numpy is gone. */
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
- export interface ColumnDigest {
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)) {