@starcried/starlite-vrm 0.0.0-stage → 0.1.0-alpha.1
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/DEFAULT_IDLE.md +21 -0
- package/LICENSE +25 -0
- package/README.md +45 -2
- package/THIRD_PARTY_LICENSES.txt +80 -0
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/dist/camera.d.ts +17 -0
- package/dist/camera.js +112 -0
- package/dist/character.d.ts +10 -0
- package/dist/character.js +134 -0
- package/dist/config.cjs +152 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +114 -0
- package/dist/default-idle-data.d.ts +1 -0
- package/dist/default-idle-data.js +2 -0
- package/dist/default-idle.d.ts +2 -0
- package/dist/default-idle.js +5 -0
- package/dist/embedded.d.ts +11 -0
- package/dist/embedded.js +154 -0
- package/dist/gaze.d.ts +28 -0
- package/dist/gaze.js +102 -0
- package/dist/host-motion.d.ts +14 -0
- package/dist/host-motion.js +97 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5243 -0
- package/dist/inspect.cjs +237 -0
- package/dist/inspect.d.ts +21 -0
- package/dist/inspect.js +256 -0
- package/dist/playback.d.ts +17 -0
- package/dist/playback.js +122 -0
- package/dist/pose-bounds.d.ts +8 -0
- package/dist/pose-bounds.js +80 -0
- package/dist/prepare.cjs +379 -0
- package/dist/prepare.d.ts +42 -0
- package/dist/prepare.js +134 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.js +1 -0
- package/dist/widget.css +69 -0
- package/package.json +56 -4
package/DEFAULT_IDLE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Default Idle provenance
|
|
2
|
+
|
|
3
|
+
The runtime embeds the currently approved Weiguang relaxed standing and
|
|
4
|
+
breathing motion as StarLiteIdle for humanoid VRM onboarding. It is encoded in
|
|
5
|
+
default-idle-data.ts and decoded locally; no model artwork or additional
|
|
6
|
+
animation download is required.
|
|
7
|
+
|
|
8
|
+
- Source: DesktopPet/desktop/motions/Idle.vrma in the author's local model project.
|
|
9
|
+
- SHA-256: 7bd5ce630f5f624c274ad2de026ad5365297c353d7f265654ed97656a738d248.
|
|
10
|
+
- Duration: 6 seconds.
|
|
11
|
+
- StarCried authored the relaxed pose and final manual editing/bake. The
|
|
12
|
+
recorded breathing layer originates from Warudo's Human@SM Breathe; this
|
|
13
|
+
asset must not be described as entirely original.
|
|
14
|
+
- The user explicitly confirmed redistribution of the selected motions,
|
|
15
|
+
including this breathing layer, on 2026-10-10 and approved bundling the
|
|
16
|
+
current Idle for model-only onboarding on 2026-10-14.
|
|
17
|
+
|
|
18
|
+
This file records provenance and the user's distribution confirmation; it
|
|
19
|
+
does not claim that Warudo's full motion library permits redistribution.
|
|
20
|
+
The model artwork and the other selected motions are not included in this
|
|
21
|
+
program asset.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, StarCried
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
17
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
18
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
19
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
20
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
21
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
22
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
23
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
24
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
25
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @starcried/starlite-vrm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
StarLite 的 WebGL2 VRM 0/1 后端,支持模型内嵌 glTF 动作、外部 VRMA、限角光标跟随、眨眼和公共气泡。浏览器入口已打包 Three.js、three-vrm 和 three-vrm-animation。
|
|
4
|
+
|
|
5
|
+
在 Node.js 22.12+ 项目中安装 alpha 版本:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @starcried/starlite-vrm@alpha
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 挂载
|
|
12
|
+
|
|
13
|
+
页面提供 `<div id="starlite"></div>`,在使用打包工具的网页项目中:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { mountModelWidget } from "@starcried/starlite-vrm";
|
|
17
|
+
import "@starcried/starlite-vrm/widget.css";
|
|
18
|
+
|
|
19
|
+
const host = document.querySelector("#starlite");
|
|
20
|
+
if (!host) throw new Error("Missing StarLite host");
|
|
21
|
+
|
|
22
|
+
const controller = await mountModelWidget(host, {
|
|
23
|
+
backend: "vrm",
|
|
24
|
+
model: "/model.vrm",
|
|
25
|
+
fallback: "/portrait.png",
|
|
26
|
+
dialogue: { click: { lines: ["你好。", "我在这里。"] } },
|
|
27
|
+
interactions: { pointer: true },
|
|
28
|
+
render: { fps: 30, maxDevicePixelRatio: 1.5 },
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
上例不需要另备动作文件。运行时自动使用模型名为 `Idle` 的动作,否则采用程序唯一内置的 `StarLiteIdle`(基础站姿与轻呼吸)。可通过 `animations: [{name: "Wave", url: "/Wave.vrma"}]` 添加自己的外部动作,或用 `ambient` 和 `neutralAnimation` 指定基础循环与站姿。不附带模型或其他动作库。显式 `ambient: {animations: []}` 可停用基础循环。眨眼需要有效的标准 Blink 表达,眼球跟随需要正确的 VRM lookAt 映射。模型必须自包含,不接受外部纹理或 buffer 引用。
|
|
33
|
+
|
|
34
|
+
`inspectVrm(bytes).animations` 返回模型内嵌动作的名称和时长;将名称列表传入 `embeddedAnimations`,即可在 `ambient`、点击与气泡配置中引用。实际加载后 `getAnimations()` 自动列出内嵌动作和外部 VRMA。未命名动作按顺序显示为 `Animation_1` 等;内嵌动作、外部动作名称不得重复,`StarLiteIdle` 为默认 Idle 保留名。角色配置也可放在模型 JSON 的 `extras.starlite.profile`,内容须符合数据型角色配置规范。
|
|
35
|
+
|
|
36
|
+
`pause()`/`resume()` 控制更新,`getAnimations()` 列出动作,`playAnimation()`/`stopAnimation()` 控制播放;页面组件卸载时调用 `controller.destroy()` 释放资源。`neutralAnimation` 的首帧作为停止后的基础站姿,避免无角色配置的模型回到 T 姿势。动作名区分大小写;循环在停止或被打断后结束等待。隐藏时停止动画和物理,恢复时不补算离线时间。
|
|
37
|
+
|
|
38
|
+
气泡可以独立使用,例如 `dialogue: {click: {lines: ["你好。"]}}`;需要动作时再添加 `animation` 或 `animations`。`linesByAnimation` 仍须完整对应动作池。
|
|
39
|
+
|
|
40
|
+
## 进阶
|
|
41
|
+
|
|
42
|
+
[动作、句库、相机与性能配置](https://github.com/StarCried/starlite/blob/main/docs/configuration.md) · [构建博客资源](https://github.com/StarCried/starlite/blob/main/docs/examples/prepare-vrm.cjs) · [Hexo 插件](https://github.com/StarCried/starlite/blob/main/packages/hexo/README.md) · [开发与验证](https://github.com/StarCried/starlite/blob/main/docs/development.md)
|
|
43
|
+
|
|
44
|
+
配置 CSP 的网站须为模型内嵌贴图允许适用的 `img-src blob:` 和 `connect-src blob:`。独立网页若要在手机上避免加载运行时,须在检查屏宽后动态导入;上例的静态导入会下载 JS。
|
|
45
|
+
|
|
46
|
+
BSD-2-Clause;模型和动作由使用者按各自许可提供,微光适配为可选配置。
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
THIRD-PARTY LICENSES FOR @starcried/starlite-vrm
|
|
2
|
+
|
|
3
|
+
This file is generated from the exact locked dependency sources.
|
|
4
|
+
Regenerate it with `npm run licenses:generate`.
|
|
5
|
+
|
|
6
|
+
COMPONENT INVENTORY
|
|
7
|
+
|
|
8
|
+
- @pixiv/three-vrm 3.5.5 | MIT | https://github.com/pixiv/three-vrm.git
|
|
9
|
+
- @pixiv/three-vrm-animation 3.5.5 | MIT | https://github.com/pixiv/three-vrm.git
|
|
10
|
+
- @pixiv/three-vrm-core 3.5.5 | MIT | https://github.com/pixiv/three-vrm.git
|
|
11
|
+
- @pixiv/three-vrm-materials-hdr-emissive-multiplier 3.5.5 | MIT | https://github.com/pixiv/three-vrm.git
|
|
12
|
+
- @pixiv/three-vrm-materials-mtoon 3.5.5 | MIT | https://github.com/pixiv/three-vrm.git
|
|
13
|
+
- @pixiv/three-vrm-materials-v0compat 3.5.5 | MIT | https://github.com/pixiv/three-vrm.git
|
|
14
|
+
- @pixiv/three-vrm-node-constraint 3.5.5 | MIT | https://github.com/pixiv/three-vrm.git
|
|
15
|
+
- @pixiv/three-vrm-springbone 3.5.5 | MIT | https://github.com/pixiv/three-vrm.git
|
|
16
|
+
- three 0.186.0 | MIT | https://github.com/mrdoob/three.js
|
|
17
|
+
|
|
18
|
+
LICENSE AND NOTICE TEXTS
|
|
19
|
+
|
|
20
|
+
================================================================================
|
|
21
|
+
SHA-256: 279ec82987aec7e72ecb9850bb704a87e352d577bc7581454c30f3551a88ea92
|
|
22
|
+
Applies to:
|
|
23
|
+
- @pixiv/three-vrm 3.5.5 (LICENSE)
|
|
24
|
+
- @pixiv/three-vrm-animation 3.5.5 (LICENSE)
|
|
25
|
+
- @pixiv/three-vrm-core 3.5.5 (LICENSE)
|
|
26
|
+
- @pixiv/three-vrm-materials-hdr-emissive-multiplier 3.5.5 (LICENSE)
|
|
27
|
+
- @pixiv/three-vrm-materials-mtoon 3.5.5 (LICENSE)
|
|
28
|
+
- @pixiv/three-vrm-materials-v0compat 3.5.5 (LICENSE)
|
|
29
|
+
- @pixiv/three-vrm-node-constraint 3.5.5 (LICENSE)
|
|
30
|
+
- @pixiv/three-vrm-springbone 3.5.5 (LICENSE)
|
|
31
|
+
|
|
32
|
+
Copyright (c) 2019-2026 pixiv Inc.
|
|
33
|
+
|
|
34
|
+
MIT License
|
|
35
|
+
|
|
36
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
37
|
+
a copy of this software and associated documentation files (the
|
|
38
|
+
"Software"), to deal in the Software without restriction, including
|
|
39
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
40
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
41
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
42
|
+
the following conditions:
|
|
43
|
+
|
|
44
|
+
The above copyright notice and this permission notice shall be
|
|
45
|
+
included in all copies or substantial portions of the Software.
|
|
46
|
+
|
|
47
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
48
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
49
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
50
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
51
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
52
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
53
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
54
|
+
|
|
55
|
+
================================================================================
|
|
56
|
+
SHA-256: 8b378ebe60e2fe500158cb0ac71cb5e8b7d92953c2abcc63a0eb90499653b5bc
|
|
57
|
+
Applies to:
|
|
58
|
+
- three 0.186.0 (LICENSE)
|
|
59
|
+
|
|
60
|
+
The MIT License
|
|
61
|
+
|
|
62
|
+
Copyright © 2010-2026 three.js authors
|
|
63
|
+
|
|
64
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
65
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
66
|
+
in the Software without restriction, including without limitation the rights
|
|
67
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
68
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
69
|
+
furnished to do so, subject to the following conditions:
|
|
70
|
+
|
|
71
|
+
The above copyright notice and this permission notice shall be included in
|
|
72
|
+
all copies or substantial portions of the Software.
|
|
73
|
+
|
|
74
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
75
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
76
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
77
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
78
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
79
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
80
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
The browser bundle includes Three.js and Pixiv three-vrm (including its VRM
|
|
4
|
+
animation, MToon, constraints and spring-bone packages), distributed under MIT.
|
|
5
|
+
Exact versions and license texts are in THIRD_PARTY_LICENSES.txt.
|
|
6
|
+
|
|
7
|
+
No third-party desktop-pet code or character model artwork is included.
|
|
8
|
+
The bundled StarLiteIdle contains the approved relaxed pose and a breathing
|
|
9
|
+
layer originating from Warudo's Human@SM Breathe. Its hash, authorship and the
|
|
10
|
+
user's redistribution confirmation are recorded in DEFAULT_IDLE.md; it is
|
|
11
|
+
not represented as a wholly original or separately MIT-licensed motion.
|
|
12
|
+
Other model and motion rights remain the responsibility of their owner.
|
package/dist/camera.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Box3, PerspectiveCamera, Vector3, type Object3D } from "three";
|
|
2
|
+
import type { ModelViewport, VrmWidgetConfig } from "./types.js";
|
|
3
|
+
/** Measure the visible, posed mesh once. Hidden accessories and stale skin bounds
|
|
4
|
+
* must not widen the portrait, and a later resize must not chase a moving hand. */
|
|
5
|
+
export declare function visibleModelBounds(root: Object3D): Box3;
|
|
6
|
+
export declare function framePortrait(camera: PerspectiveCamera, bounds: Box3, aspect: number, render: VrmWidgetConfig["render"], options: VrmWidgetConfig["camera"], anchor?: Vector3): Vector3;
|
|
7
|
+
/** Expand the transparent surface around a fixed logical portrait. Padding is
|
|
8
|
+
* quantized and retained until destroy, so an Idle return never rescales or
|
|
9
|
+
* moves the character and small spring movements do not resize every frame. */
|
|
10
|
+
export declare class ExpandedViewport {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(padding?: NonNullable<VrmWidgetConfig["render"]>["viewportPadding"]);
|
|
13
|
+
get rect(): ModelViewport;
|
|
14
|
+
resize(width: number, height: number): boolean;
|
|
15
|
+
expand(camera: PerspectiveCamera, bounds: Box3): boolean;
|
|
16
|
+
apply(camera: PerspectiveCamera): void;
|
|
17
|
+
}
|
package/dist/camera.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Box3, MathUtils, Mesh, PerspectiveCamera, Vector3 } from "three";
|
|
2
|
+
/** Measure the visible, posed mesh once. Hidden accessories and stale skin bounds
|
|
3
|
+
* must not widen the portrait, and a later resize must not chase a moving hand. */
|
|
4
|
+
export function visibleModelBounds(root) {
|
|
5
|
+
root.updateMatrixWorld(true);
|
|
6
|
+
const bounds = new Box3(), point = new Vector3();
|
|
7
|
+
root.traverseVisible(object => {
|
|
8
|
+
if (!(object instanceof Mesh))
|
|
9
|
+
return;
|
|
10
|
+
const mesh = object;
|
|
11
|
+
mesh.skeleton?.update();
|
|
12
|
+
const positions = mesh.geometry.getAttribute("position");
|
|
13
|
+
if (!positions)
|
|
14
|
+
return;
|
|
15
|
+
for (let i = 0; i < positions.count; i++)
|
|
16
|
+
bounds.expandByPoint(mesh.getVertexPosition(i, point).applyMatrix4(mesh.matrixWorld));
|
|
17
|
+
});
|
|
18
|
+
const size = bounds.getSize(new Vector3()), center = bounds.getCenter(new Vector3());
|
|
19
|
+
if (![...size, ...center].every(Number.isFinite) || size.length() === 0)
|
|
20
|
+
throw new Error("invalid model bounds");
|
|
21
|
+
return bounds;
|
|
22
|
+
}
|
|
23
|
+
export function framePortrait(camera, bounds, aspect, render, options, anchor) {
|
|
24
|
+
const size = bounds.getSize(new Vector3()), target = bounds.getCenter(new Vector3());
|
|
25
|
+
target.y = bounds.min.y + size.y * (options?.targetHeight ?? .5);
|
|
26
|
+
if (anchor)
|
|
27
|
+
target.copy(anchor);
|
|
28
|
+
camera.fov = options?.fovDegrees ?? 30;
|
|
29
|
+
camera.aspect = aspect;
|
|
30
|
+
const yaw = MathUtils.degToRad(options?.yawDegrees ?? 0), pitch = MathUtils.degToRad(options?.pitchDegrees ?? 0);
|
|
31
|
+
const normal = new Vector3(Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch));
|
|
32
|
+
const right = new Vector3().crossVectors(camera.up, normal).normalize(), up = new Vector3().crossVectors(normal, right).normalize();
|
|
33
|
+
const tan = Math.tan(MathUtils.degToRad(camera.fov / 2)) * (render?.fitPadding ?? .9);
|
|
34
|
+
let distance = 0;
|
|
35
|
+
for (const x of [bounds.min.x, bounds.max.x])
|
|
36
|
+
for (const y of [bounds.min.y, bounds.max.y])
|
|
37
|
+
for (const z of [bounds.min.z, bounds.max.z]) {
|
|
38
|
+
const offset = new Vector3(x, y, z).sub(target);
|
|
39
|
+
distance = Math.max(distance, offset.dot(normal) + Math.max(Math.abs(offset.dot(right)) / (tan * aspect), Math.abs(offset.dot(up)) / tan));
|
|
40
|
+
}
|
|
41
|
+
camera.position.copy(target).addScaledVector(normal, distance / (render?.initialScale ?? 1));
|
|
42
|
+
camera.lookAt(target);
|
|
43
|
+
camera.updateProjectionMatrix();
|
|
44
|
+
camera.updateMatrixWorld(true);
|
|
45
|
+
return target;
|
|
46
|
+
}
|
|
47
|
+
/** Expand the transparent surface around a fixed logical portrait. Padding is
|
|
48
|
+
* quantized and retained until destroy, so an Idle return never rescales or
|
|
49
|
+
* moves the character and small spring movements do not resize every frame. */
|
|
50
|
+
export class ExpandedViewport {
|
|
51
|
+
#width = 0;
|
|
52
|
+
#height = 0;
|
|
53
|
+
#left = 0;
|
|
54
|
+
#top = 0;
|
|
55
|
+
#right = 0;
|
|
56
|
+
#bottom = 0;
|
|
57
|
+
#point = new Vector3();
|
|
58
|
+
#fixed;
|
|
59
|
+
constructor(padding) {
|
|
60
|
+
this.#fixed = padding !== undefined;
|
|
61
|
+
if (padding) {
|
|
62
|
+
this.#left = padding.left;
|
|
63
|
+
this.#top = padding.top;
|
|
64
|
+
this.#right = padding.right;
|
|
65
|
+
this.#bottom = padding.bottom;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
get rect() {
|
|
69
|
+
return { left: 0 - this.#left, top: 0 - this.#top, width: this.#width + this.#left + this.#right,
|
|
70
|
+
height: this.#height + this.#top + this.#bottom, baseWidth: this.#width, baseHeight: this.#height };
|
|
71
|
+
}
|
|
72
|
+
resize(width, height) {
|
|
73
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
|
74
|
+
throw new Error("invalid portrait viewport");
|
|
75
|
+
if (width === this.#width && height === this.#height)
|
|
76
|
+
return false;
|
|
77
|
+
this.#width = width;
|
|
78
|
+
this.#height = height;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
expand(camera, bounds) {
|
|
82
|
+
if (this.#fixed || bounds.isEmpty())
|
|
83
|
+
return false;
|
|
84
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
85
|
+
for (const x of [bounds.min.x, bounds.max.x])
|
|
86
|
+
for (const y of [bounds.min.y, bounds.max.y])
|
|
87
|
+
for (const z of [bounds.min.z, bounds.max.z]) {
|
|
88
|
+
const p = this.#point.set(x, y, z).project(camera);
|
|
89
|
+
const px = (p.x + 1) * this.#width / 2, py = (1 - p.y) * this.#height / 2;
|
|
90
|
+
if (![px, py].every(Number.isFinite))
|
|
91
|
+
throw new Error("non-finite portrait projection");
|
|
92
|
+
minX = Math.min(minX, px);
|
|
93
|
+
minY = Math.min(minY, py);
|
|
94
|
+
maxX = Math.max(maxX, px);
|
|
95
|
+
maxY = Math.max(maxY, py);
|
|
96
|
+
}
|
|
97
|
+
const grow = (current, required) => Math.max(current, Math.ceil(required / 32) * 32);
|
|
98
|
+
const left = grow(this.#left, 8 - minX), top = grow(this.#top, 8 - minY);
|
|
99
|
+
const right = grow(this.#right, maxX + 8 - this.#width), bottom = grow(this.#bottom, maxY + 8 - this.#height);
|
|
100
|
+
if (left === this.#left && top === this.#top && right === this.#right && bottom === this.#bottom)
|
|
101
|
+
return false;
|
|
102
|
+
this.#left = left;
|
|
103
|
+
this.#top = top;
|
|
104
|
+
this.#right = right;
|
|
105
|
+
this.#bottom = bottom;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
apply(camera) {
|
|
109
|
+
const rect = this.rect;
|
|
110
|
+
camera.setViewOffset(this.#width, this.#height, rect.left, rect.top, rect.width, rect.height);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { VRM } from "@pixiv/three-vrm";
|
|
2
|
+
import type { CharacterProfile } from "./types.js";
|
|
3
|
+
export declare function sampleCurve(curve: readonly (readonly [number, number])[], time: number): number;
|
|
4
|
+
export declare class CharacterBehavior {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(vrm: VRM, profile?: CharacterProfile);
|
|
7
|
+
beforeExpressions(delta: number, authoredBlink?: boolean): void;
|
|
8
|
+
afterExpressions(): void;
|
|
9
|
+
reset(): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Quaternion, Vector3 } from "three";
|
|
2
|
+
export function sampleCurve(curve, time) {
|
|
3
|
+
for (let i = 1; i < curve.length; i++) {
|
|
4
|
+
const a = curve[i - 1], b = curve[i];
|
|
5
|
+
if (time <= b[0])
|
|
6
|
+
return a[1] + (b[1] - a[1]) * Math.max(0, (time - a[0]) / (b[0] - a[0]));
|
|
7
|
+
}
|
|
8
|
+
return curve.at(-1)?.[1] ?? 0;
|
|
9
|
+
}
|
|
10
|
+
export class CharacterBehavior {
|
|
11
|
+
#vrm;
|
|
12
|
+
#profile;
|
|
13
|
+
#morphs = new Map();
|
|
14
|
+
#earBones = new Map();
|
|
15
|
+
#baseEar = new Map();
|
|
16
|
+
#time = 0;
|
|
17
|
+
#weights = "";
|
|
18
|
+
#blinkAmount = 0;
|
|
19
|
+
#authoredBlink;
|
|
20
|
+
constructor(vrm, profile) {
|
|
21
|
+
this.#vrm = vrm;
|
|
22
|
+
this.#profile = profile;
|
|
23
|
+
if (!profile)
|
|
24
|
+
return;
|
|
25
|
+
vrm.scene.traverse((object) => {
|
|
26
|
+
const mesh = object;
|
|
27
|
+
for (const [name, index] of Object.entries(mesh.morphTargetDictionary ?? {})) {
|
|
28
|
+
const targets = this.#morphs.get(name) ?? [];
|
|
29
|
+
targets.push({ mesh, index });
|
|
30
|
+
this.#morphs.set(name, targets);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
for (const name of profile.earBones) {
|
|
34
|
+
const bone = findAuthoredNode(vrm.scene, name);
|
|
35
|
+
if (!bone)
|
|
36
|
+
throw new Error(`character profile is missing bone: ${name}`);
|
|
37
|
+
this.#earBones.set(name, bone);
|
|
38
|
+
this.#baseEar.set(name, { rotation: bone.quaternion.clone(), position: bone.position.clone() });
|
|
39
|
+
}
|
|
40
|
+
for (const value of Object.values(profile.expressions)) {
|
|
41
|
+
if (!vrm.expressionManager?.getExpression(value.expression))
|
|
42
|
+
throw new Error(`character expression is missing: ${value.expression}`);
|
|
43
|
+
if (value.blink && !this.#morphs.has(value.blink))
|
|
44
|
+
throw new Error(`character blink morph is missing: ${value.blink}`);
|
|
45
|
+
}
|
|
46
|
+
for (const [name, visible] of [[profile.accessories.collar, profile.accessories.collarVisible], [profile.accessories.cone, profile.accessories.coneVisible]]) {
|
|
47
|
+
const object = findAuthoredNode(vrm.scene, name);
|
|
48
|
+
if (!object)
|
|
49
|
+
throw new Error(`character accessory is missing: ${name}`);
|
|
50
|
+
object.visible = visible;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
beforeExpressions(delta, authoredBlink = false) {
|
|
54
|
+
this.#time += delta;
|
|
55
|
+
if (authoredBlink && !this.#profile)
|
|
56
|
+
return;
|
|
57
|
+
const manager = this.#vrm.expressionManager;
|
|
58
|
+
// Recipes without a correction already have closed eyes (e.g. Proud), so
|
|
59
|
+
// their share also replaces Basis. Additive totals above one stay additive.
|
|
60
|
+
const total = this.#profile ? Object.values(this.#profile.expressions).reduce((sum, item) => sum + Math.min(1, Math.max(0, manager?.getValue(item.expression) ?? 0)), 0) : 0;
|
|
61
|
+
// Each custom correction closes its expression's share of the eyelids.
|
|
62
|
+
// Ordinary blink must still close the remaining Basis share during fades.
|
|
63
|
+
const curve = this.#profile?.blink.curve ?? [[0, 0], [3.4, 0], [3.48, 1], [3.52, 1], [3.65, 0], [4.7, 0]];
|
|
64
|
+
this.#authoredBlink = authoredBlink ? manager?.getValue("blink") ?? 0 : undefined;
|
|
65
|
+
this.#blinkAmount = this.#authoredBlink ?? sampleCurve(curve, this.#time % (this.#profile?.blink.period ?? 4.7));
|
|
66
|
+
manager?.setValue("blink", Math.max(0, 1 - total) * this.#blinkAmount);
|
|
67
|
+
}
|
|
68
|
+
afterExpressions() {
|
|
69
|
+
const profile = this.#profile;
|
|
70
|
+
if (!profile)
|
|
71
|
+
return;
|
|
72
|
+
const weights = Object.entries(profile.expressions).map(([name, item]) => [name, Math.min(1, Math.max(0, this.#vrm.expressionManager?.getValue(item.expression) ?? 0))]);
|
|
73
|
+
const key = weights.map(([, weight]) => weight.toFixed(4)).join(",");
|
|
74
|
+
if (key !== this.#weights) {
|
|
75
|
+
if (!weights.some(([, weight]) => weight > .001) || !this.#weights)
|
|
76
|
+
this.#time = 0;
|
|
77
|
+
this.#weights = key;
|
|
78
|
+
// Set the authored ear rest pose only when expression weights change;
|
|
79
|
+
// steady frames leave the spring solver's inertia intact.
|
|
80
|
+
const total = weights.reduce((sum, [, weight]) => sum + weight, 0);
|
|
81
|
+
for (const [name, bone] of this.#earBones) {
|
|
82
|
+
const neutral = profile.earPoses.Neutral?.[name];
|
|
83
|
+
const base = this.#baseEar.get(name);
|
|
84
|
+
const reference = neutral ? new Quaternion(...neutral.rotation) : base.rotation;
|
|
85
|
+
const rotation = reference.clone();
|
|
86
|
+
let accumulated = Math.max(0, 1 - total);
|
|
87
|
+
const position = (neutral?.position ? new Vector3(...neutral.position) : base.position.clone()).multiplyScalar(accumulated);
|
|
88
|
+
for (const [emotion, weight] of weights) {
|
|
89
|
+
if (weight <= 0)
|
|
90
|
+
continue;
|
|
91
|
+
const pose = profile.earPoses[emotion]?.[name];
|
|
92
|
+
if (!pose)
|
|
93
|
+
throw new Error(`missing ear pose: ${emotion}/${name}`);
|
|
94
|
+
rotation.slerp(new Quaternion(...pose.rotation), weight / (accumulated + weight));
|
|
95
|
+
position.addScaledVector(pose.position ? new Vector3(...pose.position) : base.position, weight);
|
|
96
|
+
accumulated += weight;
|
|
97
|
+
}
|
|
98
|
+
bone.quaternion.copy(rotation);
|
|
99
|
+
bone.position.copy(position.divideScalar(Math.max(1, total)));
|
|
100
|
+
// Spring joints disable matrixAutoUpdate. Their rest state reads both
|
|
101
|
+
// quaternion and matrix, so compose the authored local pose before
|
|
102
|
+
// propagating world matrices or capturing a new spring rest direction.
|
|
103
|
+
// Otherwise a changing expression mixes the new rotation with the
|
|
104
|
+
// preceding frame's simulated matrix and makes the ears jump.
|
|
105
|
+
bone.updateMatrix();
|
|
106
|
+
}
|
|
107
|
+
this.#vrm.scene.updateMatrixWorld(true);
|
|
108
|
+
// Expression changes establish a new authored rest direction for ears only.
|
|
109
|
+
for (const joint of this.#vrm.springBoneManager?.joints ?? []) {
|
|
110
|
+
if (profile.earBones.some((name) => this.#earBones.get(name) === joint.bone))
|
|
111
|
+
joint.setInitState();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const [name, weight] of weights) {
|
|
115
|
+
const blink = profile.expressions[name]?.blink;
|
|
116
|
+
if (!blink)
|
|
117
|
+
continue;
|
|
118
|
+
for (const { mesh, index } of this.#morphs.get(blink) ?? [])
|
|
119
|
+
mesh.morphTargetInfluences[index] = weight * this.#blinkAmount;
|
|
120
|
+
}
|
|
121
|
+
// Only the applied neutral share changes; the animation's sampled value
|
|
122
|
+
// remains available to the next authored frame or captured-pose transition.
|
|
123
|
+
if (this.#authoredBlink !== undefined)
|
|
124
|
+
this.#vrm.expressionManager?.setValue("blink", this.#authoredBlink);
|
|
125
|
+
this.#authoredBlink = undefined;
|
|
126
|
+
}
|
|
127
|
+
reset() { this.#time = 0; this.#weights = ""; this.#blinkAmount = 0; this.#authoredBlink = undefined; }
|
|
128
|
+
}
|
|
129
|
+
function findAuthoredNode(root, name) {
|
|
130
|
+
let found;
|
|
131
|
+
root.traverse((node) => { if (node.name === name || node.userData.name === name)
|
|
132
|
+
found = node; });
|
|
133
|
+
return found;
|
|
134
|
+
}
|
package/dist/config.cjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// src/config.ts
|
|
20
|
+
var config_exports = {};
|
|
21
|
+
__export(config_exports, {
|
|
22
|
+
validateCharacterProfile: () => validateCharacterProfile,
|
|
23
|
+
validateVrmWidgetConfig: () => validateVrmWidgetConfig
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(config_exports);
|
|
26
|
+
|
|
27
|
+
// ../core/dist/dialogue.js
|
|
28
|
+
function resolveDialogueSentences(input, animations, field) {
|
|
29
|
+
const resolve = (value, path) => {
|
|
30
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 128) {
|
|
31
|
+
throw new Error(`${path} must contain 1..=128 sentences`);
|
|
32
|
+
}
|
|
33
|
+
return value.map((line, index) => {
|
|
34
|
+
if (typeof line !== "string")
|
|
35
|
+
throw new Error(`${path}[${index}] must be a string`);
|
|
36
|
+
const text = line.trim();
|
|
37
|
+
const bytes = new TextEncoder().encode(text).byteLength;
|
|
38
|
+
if (bytes < 1 || bytes > 1024)
|
|
39
|
+
throw new Error(`${path}[${index}] must contain 1..=1024 UTF-8 bytes`);
|
|
40
|
+
return text;
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const lines = input.lines === void 0 ? void 0 : resolve(input.lines, `${field}.lines`);
|
|
44
|
+
const mapping = input.linesByAnimation;
|
|
45
|
+
if (mapping === void 0) {
|
|
46
|
+
if (!lines)
|
|
47
|
+
throw new Error(`${field}.lines or linesByAnimation is required`);
|
|
48
|
+
return { lines };
|
|
49
|
+
}
|
|
50
|
+
if (!mapping || typeof mapping !== "object" || Array.isArray(mapping)) {
|
|
51
|
+
throw new Error(`${field}.linesByAnimation must be an animation-to-sentences object`);
|
|
52
|
+
}
|
|
53
|
+
if (!animations.length)
|
|
54
|
+
throw new Error(`${field}.linesByAnimation needs an animation`);
|
|
55
|
+
const selected = new Set(animations);
|
|
56
|
+
const keys = Object.keys(mapping);
|
|
57
|
+
if (keys.length !== selected.size || keys.some((name) => !selected.has(name)) || animations.some((name) => !Object.hasOwn(mapping, name))) {
|
|
58
|
+
throw new Error(`${field}.linesByAnimation must cover exactly the selected animations`);
|
|
59
|
+
}
|
|
60
|
+
const linesByAnimation = Object.fromEntries(keys.map((name) => [name, resolve(mapping[name], `${field}.linesByAnimation[${JSON.stringify(name)}]`)]));
|
|
61
|
+
return { ...lines ? { lines } : {}, linesByAnimation };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/config.ts
|
|
65
|
+
function finite(value, min, max) {
|
|
66
|
+
return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
|
67
|
+
}
|
|
68
|
+
function validateVrmWidgetConfig(config) {
|
|
69
|
+
if (!config || config.backend !== void 0 && config.backend !== "vrm") throw new Error("invalid VRM backend");
|
|
70
|
+
if (config.interactions?.passThrough !== void 0 && typeof config.interactions.passThrough !== "boolean") throw new Error("interactions.passThrough must be boolean");
|
|
71
|
+
if (config.interactions?.passThrough && config.interactions.drag) throw new Error("passThrough and drag cannot both be enabled");
|
|
72
|
+
for (const key of ["model", "fallback"]) if (typeof config[key] !== "string" || !config[key].trim()) throw new Error(`${key} is required`);
|
|
73
|
+
if (config.render?.fps !== void 0 && config.render.fps !== 30 && config.render.fps !== 60 && config.render.fps !== "display") throw new Error("frame rate must be 30, 60 or display");
|
|
74
|
+
const checks = [
|
|
75
|
+
[config.render?.maxDevicePixelRatio ?? 1.5, 1, 1.5],
|
|
76
|
+
[config.render?.initialScale ?? 1, 0.25, 3],
|
|
77
|
+
[config.render?.fitPadding ?? 0.9, 0.1, 1],
|
|
78
|
+
[config.display?.width ?? 320, 1, 4096],
|
|
79
|
+
[config.display?.height ?? 480, 1, 4096],
|
|
80
|
+
[config.camera?.yawDegrees ?? 0, -45, 45],
|
|
81
|
+
[config.camera?.pitchDegrees ?? 0, -20, 20],
|
|
82
|
+
[config.camera?.fovDegrees ?? 30, 15, 50],
|
|
83
|
+
[config.camera?.targetHeight ?? 0.5, 0.35, 0.65],
|
|
84
|
+
[config.gaze?.maxYawDegrees ?? 25, 0, 45],
|
|
85
|
+
[config.gaze?.maxPitchDegrees ?? 15, 0, 30],
|
|
86
|
+
[config.gaze?.smoothingMs ?? 100, 20, 1e3],
|
|
87
|
+
[config.mobile?.breakpoint ?? 768, 1, 4096],
|
|
88
|
+
[config.network?.requestTimeoutMs ?? 3e4, 100, 12e4],
|
|
89
|
+
[config.network?.modelMaxBytes ?? 64 * 1024 * 1024, 1, 64 * 1024 * 1024]
|
|
90
|
+
];
|
|
91
|
+
if (checks.some(([value, min, max]) => !finite(value, min, max))) throw new Error("invalid VRM display or network limits");
|
|
92
|
+
const padding = config.render?.viewportPadding;
|
|
93
|
+
if (padding !== void 0) {
|
|
94
|
+
if (!padding || typeof padding !== "object" || Array.isArray(padding) || [padding.left, padding.top, padding.right, padding.bottom].some((value) => !finite(value, 0, 1024)) || (config.display?.width ?? 320) + padding.left + padding.right > 4096 || (config.display?.height ?? 480) + padding.top + padding.bottom > 4096) throw new Error("invalid fixed viewport padding");
|
|
95
|
+
}
|
|
96
|
+
const animations = config.animations ?? [];
|
|
97
|
+
if (!Array.isArray(animations) || animations.length > 64) throw new Error("at most 64 animations are supported");
|
|
98
|
+
const names = /* @__PURE__ */ new Set();
|
|
99
|
+
if (config.embeddedAnimations !== void 0 && (!Array.isArray(config.embeddedAnimations) || config.embeddedAnimations.length > 64)) throw new Error("invalid embedded animation catalog");
|
|
100
|
+
for (const name of config.embeddedAnimations ?? []) {
|
|
101
|
+
if (typeof name !== "string" || !name.trim() || name.length > 128 || names.has(name)) throw new Error("invalid or duplicate embedded animation");
|
|
102
|
+
names.add(name);
|
|
103
|
+
}
|
|
104
|
+
for (const animation of animations) {
|
|
105
|
+
if (!animation || typeof animation.name !== "string" || !animation.name.trim() || animation.name.length > 128 || names.has(animation.name) || typeof animation.url !== "string" || !animation.url.trim()) throw new Error("invalid or duplicate animation");
|
|
106
|
+
if (animation.durationMs !== void 0 && !finite(animation.durationMs, 1, 6e5)) throw new Error("invalid animation duration");
|
|
107
|
+
names.add(animation.name);
|
|
108
|
+
}
|
|
109
|
+
names.add("StarLiteIdle");
|
|
110
|
+
for (const name of config.ambient?.animations ?? []) if (!names.has(name)) throw new Error(`unknown ambient animation: ${name}`);
|
|
111
|
+
if ((config.ambient?.animations?.length ?? 0) > 1) throw new Error("VRM supports one explicitly configured ambient clip");
|
|
112
|
+
if (config.neutralAnimation !== void 0 && !names.has(config.neutralAnimation)) throw new Error("unknown neutral animation");
|
|
113
|
+
const click = config.interactions?.click;
|
|
114
|
+
const pool = (value) => Array.isArray(value) && value.length > 0 && value.length <= 20 && new Set(value).size === value.length && value.every((name) => typeof name === "string" && names.has(name));
|
|
115
|
+
if (click && (click.animations !== void 0 ? !pool(click.animations) : !click.animation || !names.has(click.animation))) throw new Error("Click must name available animations");
|
|
116
|
+
for (const [name, scene] of Object.entries(config.dialogue ?? {})) {
|
|
117
|
+
if (!scene) continue;
|
|
118
|
+
if (scene.animations !== void 0 && !pool(scene.animations)) throw new Error("invalid dialogue animation pool");
|
|
119
|
+
if (scene.animation !== void 0 && !names.has(scene.animation)) throw new Error("invalid dialogue scene");
|
|
120
|
+
resolveDialogueSentences(scene, scene.animations ?? (scene.animation ? [scene.animation] : []), `dialogue.${name}`);
|
|
121
|
+
}
|
|
122
|
+
if (config.profile && typeof config.profile !== "string") validateCharacterProfile(config.profile);
|
|
123
|
+
}
|
|
124
|
+
function validateCharacterProfile(input) {
|
|
125
|
+
if (!input || typeof input !== "object") throw new Error("invalid character profile");
|
|
126
|
+
const profile = input;
|
|
127
|
+
if (profile.schemaVersion !== 1 || profile.type !== "weiguang") throw new Error("unsupported character profile");
|
|
128
|
+
const record = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
129
|
+
const pose = (value) => {
|
|
130
|
+
if (!record(value) || Object.keys(value).length > 256) return false;
|
|
131
|
+
return Object.values(value).every((item) => Array.isArray(item?.rotation) && item.rotation.length === 4 && item.rotation.every((v) => finite(v, -1, 1)) && Math.abs(Math.hypot(...item.rotation) - 1) < 0.01 && (item.position === void 0 || Array.isArray(item.position) && item.position.length === 3 && item.position.every((v) => finite(v, -10, 10))));
|
|
132
|
+
};
|
|
133
|
+
if (!pose(profile.neutralPose) || !record(profile.earPoses) || !Object.values(profile.earPoses).every(pose)) throw new Error("invalid character bone poses");
|
|
134
|
+
if (!Array.isArray(profile.earBones) || profile.earBones.length > 32 || profile.earBones.some((name) => typeof name !== "string" || !name)) throw new Error("invalid ear bones");
|
|
135
|
+
if (!record(profile.expressions) || Object.keys(profile.expressions).length > 64 || !Object.values(profile.expressions).every((v) => typeof v?.expression === "string" && (v.blink === void 0 || typeof v.blink === "string"))) throw new Error("invalid expressions");
|
|
136
|
+
if (profile.neutralAnimation !== void 0 && (typeof profile.neutralAnimation !== "string" || !profile.neutralAnimation.trim() || profile.neutralAnimation.length > 128)) throw new Error("invalid neutral animation");
|
|
137
|
+
for (const emotion of ["Neutral", ...Object.keys(profile.expressions)]) if (!profile.earPoses[emotion] || profile.earBones.some((name) => !profile.earPoses[emotion]?.[name])) throw new Error("incomplete ear poses");
|
|
138
|
+
const a = profile.accessories;
|
|
139
|
+
if (!a || typeof a.collar !== "string" || typeof a.cone !== "string" || typeof a.collarVisible !== "boolean" || typeof a.coneVisible !== "boolean") throw new Error("invalid accessories");
|
|
140
|
+
const blink = profile.blink;
|
|
141
|
+
if (!blink || !finite(blink.period, 1, 60) || !Array.isArray(blink.curve) || blink.curve.length < 2 || blink.curve.length > 64) throw new Error("invalid blink timing");
|
|
142
|
+
let previous = -1;
|
|
143
|
+
for (const point of blink.curve) {
|
|
144
|
+
if (!Array.isArray(point) || point.length !== 2 || !finite(point[0], 0, blink.period) || point[0] <= previous || !finite(point[1], 0, 1)) throw new Error("invalid blink curve");
|
|
145
|
+
previous = point[0];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
149
|
+
0 && (module.exports = {
|
|
150
|
+
validateCharacterProfile,
|
|
151
|
+
validateVrmWidgetConfig
|
|
152
|
+
});
|
package/dist/config.d.ts
ADDED