@aether-stack-dev/client-sdk 1.2.4 → 1.2.5
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/README.md +72 -0
- package/dist/avatar/VRMAvatar.d.ts +14 -1
- package/dist/avatar/constants.d.ts +3 -0
- package/dist/avatar/index.d.ts +2 -2
- package/dist/index.esm.js +3 -0
- package/dist/index.js +3 -0
- package/dist/react/index.d.ts +2 -2
- package/dist/react/useAStackCSR.d.ts +9 -0
- package/dist/react.esm.js +117 -7
- package/dist/react.js +116 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,6 +137,78 @@ The hook returns:
|
|
|
137
137
|
|
|
138
138
|
Pass `autoConnect: true` in options to connect automatically on mount.
|
|
139
139
|
|
|
140
|
+
The hook also accepts a `character` config for custom avatars (see below).
|
|
141
|
+
|
|
142
|
+
## Custom Characters
|
|
143
|
+
|
|
144
|
+
AStack supports custom VRM characters. Any VRM model with ARKit blendshapes will animate with the server's Audio2Face output.
|
|
145
|
+
|
|
146
|
+
### VRMAvatar Props
|
|
147
|
+
|
|
148
|
+
| Prop | Type | Default | Description |
|
|
149
|
+
|------|------|---------|-------------|
|
|
150
|
+
| `blendshapes` | `number[]` | **required** | 52 ARKit blendshape values |
|
|
151
|
+
| `modelUrl` | `string` | `'/models/avatar.vrm'` | URL of the VRM model |
|
|
152
|
+
| `blendshapeMap` | `Record<string, string>` | — | Custom blendshape name mapping (ARKit → model) |
|
|
153
|
+
| `onModelLoad` | `(report: ModelCompatibilityReport) => void` | — | Callback with compatibility report after load |
|
|
154
|
+
| `maxModelSize` | `number` | `31457280` (30MB) | Max model file size in bytes (0 to disable) |
|
|
155
|
+
| `width` | `number` | `400` | Canvas width |
|
|
156
|
+
| `height` | `number` | `400` | Canvas height |
|
|
157
|
+
| `backgroundColor` | `number` | `0x1a1a2e` | Background color |
|
|
158
|
+
|
|
159
|
+
### Custom Model URL
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
<VRMAvatar blendshapes={blendshapes} modelUrl="https://cdn.example.com/my-character.vrm" />
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Blendshape Mapping
|
|
166
|
+
|
|
167
|
+
For models with non-standard blendshape names, provide a mapping from ARKit names to your model's names:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import { VROID_BLENDSHAPE_MAP } from '@aether-stack-dev/client-sdk';
|
|
171
|
+
|
|
172
|
+
// Use the built-in VRoid Studio preset
|
|
173
|
+
<VRMAvatar blendshapes={blendshapes} blendshapeMap={VROID_BLENDSHAPE_MAP} />
|
|
174
|
+
|
|
175
|
+
// Or provide a custom mapping
|
|
176
|
+
<VRMAvatar blendshapes={blendshapes} blendshapeMap={{ jawOpen: 'mouth_open', eyeBlinkLeft: 'blink_L' }} />
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Compatibility Report
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
<VRMAvatar
|
|
183
|
+
blendshapes={blendshapes}
|
|
184
|
+
modelUrl="/custom.vrm"
|
|
185
|
+
onModelLoad={(report) => {
|
|
186
|
+
console.log(`${report.supported}/52 blendshapes supported`);
|
|
187
|
+
console.log('Missing:', report.missing);
|
|
188
|
+
console.log('Warnings:', report.warnings);
|
|
189
|
+
console.log('Stats:', report.modelStats);
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Hook Character Config
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
const { blendshapes, characterConfig } = useAStackCSR({
|
|
198
|
+
workerUrl, sessionToken,
|
|
199
|
+
character: {
|
|
200
|
+
modelUrl: 'https://cdn.example.com/my-character.vrm',
|
|
201
|
+
blendshapeMap: VROID_BLENDSHAPE_MAP,
|
|
202
|
+
onModelLoad: (report) => console.log(report),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Spread characterConfig directly on VRMAvatar
|
|
207
|
+
<VRMAvatar blendshapes={blendshapes} {...characterConfig} />
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
See the [full character documentation](https://astack.dev/docs/characters) for VRM requirements, ecosystem links, and integration guides.
|
|
211
|
+
|
|
140
212
|
## License
|
|
141
213
|
|
|
142
214
|
MIT
|
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
export interface ModelCompatibilityReport {
|
|
2
|
+
supported: number;
|
|
3
|
+
missing: string[];
|
|
4
|
+
warnings: string[];
|
|
5
|
+
modelStats: {
|
|
6
|
+
vertexCount: number;
|
|
7
|
+
textureCount: number;
|
|
8
|
+
morphTargetCount: number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
1
11
|
export interface VRMAvatarProps {
|
|
2
12
|
blendshapes: number[];
|
|
3
13
|
width?: number;
|
|
4
14
|
height?: number;
|
|
5
15
|
modelUrl?: string;
|
|
6
16
|
backgroundColor?: number;
|
|
17
|
+
blendshapeMap?: Record<string, string>;
|
|
18
|
+
onModelLoad?: (report: ModelCompatibilityReport) => void;
|
|
19
|
+
maxModelSize?: number;
|
|
7
20
|
}
|
|
8
|
-
export declare function VRMAvatar({ blendshapes, width, height, modelUrl, backgroundColor }: VRMAvatarProps): import("react/jsx-runtime").JSX.Element;
|
|
21
|
+
export declare function VRMAvatar({ blendshapes, width, height, modelUrl, backgroundColor, blendshapeMap, onModelLoad, maxModelSize, }: VRMAvatarProps): import("react/jsx-runtime").JSX.Element;
|
|
9
22
|
export default VRMAvatar;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export declare const ARKIT_BLENDSHAPES: readonly ["jawOpen", "jawForward", "jawLeft", "jawRight", "mouthClose", "mouthFunnel", "mouthPucker", "mouthLeft", "mouthRight", "mouthSmileLeft", "mouthSmileRight", "mouthFrownLeft", "mouthFrownRight", "mouthDimpleLeft", "mouthDimpleRight", "mouthStretchLeft", "mouthStretchRight", "mouthRollLower", "mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", "mouthPressLeft", "mouthPressRight", "mouthLowerDownLeft", "mouthLowerDownRight", "mouthUpperUpLeft", "mouthUpperUpRight", "tongueOut", "cheekPuff", "cheekSquintLeft", "cheekSquintRight", "noseSneerLeft", "noseSneerRight", "eyeBlinkLeft", "eyeBlinkRight", "eyeLookDownLeft", "eyeLookDownRight", "eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight", "eyeLookUpLeft", "eyeLookUpRight", "eyeSquintLeft", "eyeSquintRight", "eyeWideLeft", "eyeWideRight", "browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft", "browOuterUpRight"];
|
|
2
2
|
export type ARKitBlendshapeName = typeof ARKIT_BLENDSHAPES[number];
|
|
3
3
|
export declare const BLENDSHAPE_COUNT = 52;
|
|
4
|
+
export declare const CRITICAL_BLENDSHAPES: ARKitBlendshapeName[];
|
|
5
|
+
export declare const DEFAULT_BLENDSHAPE_MAP: Record<string, string>;
|
|
6
|
+
export declare const VROID_BLENDSHAPE_MAP: Record<string, string>;
|
package/dist/avatar/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { VRMAvatar } from './VRMAvatar';
|
|
2
|
-
export type { VRMAvatarProps } from './VRMAvatar';
|
|
3
|
-
export { ARKIT_BLENDSHAPES, BLENDSHAPE_COUNT } from './constants';
|
|
2
|
+
export type { VRMAvatarProps, ModelCompatibilityReport } from './VRMAvatar';
|
|
3
|
+
export { ARKIT_BLENDSHAPES, BLENDSHAPE_COUNT, CRITICAL_BLENDSHAPES, DEFAULT_BLENDSHAPE_MAP, VROID_BLENDSHAPE_MAP, } from './constants';
|
|
4
4
|
export type { ARKitBlendshapeName } from './constants';
|
package/dist/index.esm.js
CHANGED
|
@@ -18,6 +18,9 @@ const ARKIT_BLENDSHAPES = [
|
|
|
18
18
|
'browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight'
|
|
19
19
|
];
|
|
20
20
|
const BLENDSHAPE_COUNT = 52;
|
|
21
|
+
const DEFAULT_BLENDSHAPE_MAP = Object.fromEntries(ARKIT_BLENDSHAPES.map(name => [name, name]));
|
|
22
|
+
({
|
|
23
|
+
...DEFAULT_BLENDSHAPE_MAP});
|
|
21
24
|
|
|
22
25
|
const IDX_JAW_OPEN = ARKIT_BLENDSHAPES.indexOf('jawOpen');
|
|
23
26
|
const IDX_MOUTH_FUNNEL = ARKIT_BLENDSHAPES.indexOf('mouthFunnel');
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,9 @@ const ARKIT_BLENDSHAPES = [
|
|
|
22
22
|
'browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight'
|
|
23
23
|
];
|
|
24
24
|
const BLENDSHAPE_COUNT = 52;
|
|
25
|
+
const DEFAULT_BLENDSHAPE_MAP = Object.fromEntries(ARKIT_BLENDSHAPES.map(name => [name, name]));
|
|
26
|
+
({
|
|
27
|
+
...DEFAULT_BLENDSHAPE_MAP});
|
|
25
28
|
|
|
26
29
|
const IDX_JAW_OPEN = ARKIT_BLENDSHAPES.indexOf('jawOpen');
|
|
27
30
|
const IDX_MOUTH_FUNNEL = ARKIT_BLENDSHAPES.indexOf('mouthFunnel');
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { useAStackCSR } from './useAStackCSR';
|
|
2
|
-
export type { UseAStackCSROptions, UseAStackCSRReturn } from './useAStackCSR';
|
|
2
|
+
export type { UseAStackCSROptions, UseAStackCSRReturn, CharacterConfig } from './useAStackCSR';
|
|
3
3
|
export { VRMAvatar } from '../avatar/VRMAvatar';
|
|
4
|
-
export type { VRMAvatarProps } from '../avatar/VRMAvatar';
|
|
4
|
+
export type { VRMAvatarProps, ModelCompatibilityReport } from '../avatar/VRMAvatar';
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { AStackCSRClient, AStackCSRConfig, CallStatus } from '../AStackCSRClient';
|
|
2
|
+
import type { ModelCompatibilityReport } from '../avatar/VRMAvatar';
|
|
3
|
+
export interface CharacterConfig {
|
|
4
|
+
modelUrl?: string;
|
|
5
|
+
blendshapeMap?: Record<string, string>;
|
|
6
|
+
onModelLoad?: (report: ModelCompatibilityReport) => void;
|
|
7
|
+
maxModelSize?: number;
|
|
8
|
+
}
|
|
2
9
|
export interface UseAStackCSROptions extends AStackCSRConfig {
|
|
3
10
|
autoConnect?: boolean;
|
|
11
|
+
character?: CharacterConfig;
|
|
4
12
|
}
|
|
5
13
|
export interface UseAStackCSRReturn {
|
|
6
14
|
client: AStackCSRClient | null;
|
|
@@ -10,6 +18,7 @@ export interface UseAStackCSRReturn {
|
|
|
10
18
|
transcript: string;
|
|
11
19
|
response: string;
|
|
12
20
|
error: Error | null;
|
|
21
|
+
characterConfig: CharacterConfig;
|
|
13
22
|
connect: () => Promise<void>;
|
|
14
23
|
disconnect: () => void;
|
|
15
24
|
startCall: () => Promise<void>;
|
package/dist/react.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
3
3
|
import { EventEmitter } from 'eventemitter3';
|
|
4
4
|
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
5
5
|
import * as THREE from 'three';
|
|
@@ -24,6 +24,12 @@ const ARKIT_BLENDSHAPES = [
|
|
|
24
24
|
'browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight'
|
|
25
25
|
];
|
|
26
26
|
const BLENDSHAPE_COUNT = 52;
|
|
27
|
+
const CRITICAL_BLENDSHAPES = [
|
|
28
|
+
'jawOpen', 'eyeBlinkLeft', 'eyeBlinkRight',
|
|
29
|
+
];
|
|
30
|
+
const DEFAULT_BLENDSHAPE_MAP = Object.fromEntries(ARKIT_BLENDSHAPES.map(name => [name, name]));
|
|
31
|
+
({
|
|
32
|
+
...DEFAULT_BLENDSHAPE_MAP});
|
|
27
33
|
|
|
28
34
|
const IDX_JAW_OPEN = ARKIT_BLENDSHAPES.indexOf('jawOpen');
|
|
29
35
|
const IDX_MOUTH_FUNNEL = ARKIT_BLENDSHAPES.indexOf('mouthFunnel');
|
|
@@ -768,6 +774,12 @@ function useAStackCSR(options) {
|
|
|
768
774
|
clientRef.current.sendText(message);
|
|
769
775
|
}
|
|
770
776
|
}, []);
|
|
777
|
+
const characterConfig = useMemo(() => ({
|
|
778
|
+
...(options.character?.modelUrl && { modelUrl: options.character.modelUrl }),
|
|
779
|
+
...(options.character?.blendshapeMap && { blendshapeMap: options.character.blendshapeMap }),
|
|
780
|
+
...(options.character?.onModelLoad && { onModelLoad: options.character.onModelLoad }),
|
|
781
|
+
...(options.character?.maxModelSize !== undefined && { maxModelSize: options.character.maxModelSize }),
|
|
782
|
+
}), [options.character?.modelUrl, options.character?.blendshapeMap, options.character?.onModelLoad, options.character?.maxModelSize]);
|
|
771
783
|
return {
|
|
772
784
|
client: clientRef.current,
|
|
773
785
|
isConnected,
|
|
@@ -776,6 +788,7 @@ function useAStackCSR(options) {
|
|
|
776
788
|
transcript,
|
|
777
789
|
response,
|
|
778
790
|
error,
|
|
791
|
+
characterConfig,
|
|
779
792
|
connect,
|
|
780
793
|
disconnect,
|
|
781
794
|
startCall,
|
|
@@ -784,7 +797,67 @@ function useAStackCSR(options) {
|
|
|
784
797
|
};
|
|
785
798
|
}
|
|
786
799
|
|
|
787
|
-
|
|
800
|
+
const DEFAULT_MAX_MODEL_SIZE = 30 * 1024 * 1024; // 30MB
|
|
801
|
+
const HIGH_VERTEX_THRESHOLD = 100000;
|
|
802
|
+
function buildCompatibilityReport(vrm) {
|
|
803
|
+
const foundNames = new Set();
|
|
804
|
+
let vertexCount = 0;
|
|
805
|
+
let textureCount = 0;
|
|
806
|
+
let morphTargetCount = 0;
|
|
807
|
+
vrm.scene.traverse((obj) => {
|
|
808
|
+
if (obj.isMesh) {
|
|
809
|
+
const mesh = obj;
|
|
810
|
+
const geo = mesh.geometry;
|
|
811
|
+
if (geo)
|
|
812
|
+
vertexCount += geo.attributes.position?.count || 0;
|
|
813
|
+
const material = mesh.material;
|
|
814
|
+
const materials = Array.isArray(material) ? material : [material];
|
|
815
|
+
for (const mat of materials) {
|
|
816
|
+
if (mat && 'map' in mat && mat.map)
|
|
817
|
+
textureCount++;
|
|
818
|
+
}
|
|
819
|
+
const morphDict = mesh.morphTargetDictionary;
|
|
820
|
+
if (morphDict) {
|
|
821
|
+
morphTargetCount += Object.keys(morphDict).length;
|
|
822
|
+
for (const name of Object.keys(morphDict)) {
|
|
823
|
+
foundNames.add(name);
|
|
824
|
+
foundNames.add(name.toLowerCase());
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
if (vrm.expressionManager) {
|
|
830
|
+
for (const exp of vrm.expressionManager.expressions) {
|
|
831
|
+
foundNames.add(exp.expressionName);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const supported = [];
|
|
835
|
+
const missing = [];
|
|
836
|
+
for (const name of ARKIT_BLENDSHAPES) {
|
|
837
|
+
if (foundNames.has(name) || foundNames.has(name.toLowerCase())) {
|
|
838
|
+
supported.push(name);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
missing.push(name);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const warnings = [];
|
|
845
|
+
for (const name of CRITICAL_BLENDSHAPES) {
|
|
846
|
+
if (missing.includes(name)) {
|
|
847
|
+
warnings.push(`Missing critical blendshape: ${name}`);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (vertexCount > HIGH_VERTEX_THRESHOLD) {
|
|
851
|
+
warnings.push(`High vertex count (${vertexCount.toLocaleString()}) may impact performance`);
|
|
852
|
+
}
|
|
853
|
+
return {
|
|
854
|
+
supported: supported.length,
|
|
855
|
+
missing,
|
|
856
|
+
warnings,
|
|
857
|
+
modelStats: { vertexCount, textureCount, morphTargetCount },
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models/avatar.vrm', backgroundColor = 0x1a1a2e, blendshapeMap, onModelLoad, maxModelSize = DEFAULT_MAX_MODEL_SIZE, }) {
|
|
788
861
|
const containerRef = useRef(null);
|
|
789
862
|
const rendererRef = useRef(null);
|
|
790
863
|
const sceneRef = useRef(null);
|
|
@@ -831,7 +904,8 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
|
|
|
831
904
|
const morphInfluences = mesh.morphTargetInfluences;
|
|
832
905
|
if (morphDict && morphInfluences) {
|
|
833
906
|
ARKIT_BLENDSHAPES.forEach((name, i) => {
|
|
834
|
-
const
|
|
907
|
+
const mapped = blendshapeMap?.[name] ?? name;
|
|
908
|
+
const idx = morphDict[mapped] ?? morphDict[mapped.toLowerCase()];
|
|
835
909
|
if (idx !== undefined) {
|
|
836
910
|
morphInfluences[idx] = shapes[i] || 0;
|
|
837
911
|
}
|
|
@@ -839,7 +913,7 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
|
|
|
839
913
|
}
|
|
840
914
|
}
|
|
841
915
|
});
|
|
842
|
-
}, []);
|
|
916
|
+
}, [blendshapeMap]);
|
|
843
917
|
useEffect(() => {
|
|
844
918
|
const container = containerRef.current;
|
|
845
919
|
if (!container)
|
|
@@ -877,18 +951,53 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
|
|
|
877
951
|
const backLight = new THREE.DirectionalLight(0x4a9eff, 0.3);
|
|
878
952
|
backLight.position.set(-1, 1, -1);
|
|
879
953
|
scene.add(backLight);
|
|
954
|
+
let cancelled = false;
|
|
955
|
+
// Size guard: fire HEAD check in parallel — if model is too large, cancel before render
|
|
956
|
+
if (maxModelSize > 0) {
|
|
957
|
+
fetch(modelUrl, { method: 'HEAD' }).then(res => {
|
|
958
|
+
if (cancelled)
|
|
959
|
+
return;
|
|
960
|
+
const contentLength = res.headers.get('content-length');
|
|
961
|
+
if (contentLength && parseInt(contentLength, 10) > maxModelSize) {
|
|
962
|
+
cancelled = true;
|
|
963
|
+
const sizeMB = (parseInt(contentLength, 10) / 1024 / 1024).toFixed(1);
|
|
964
|
+
const limitMB = (maxModelSize / 1024 / 1024).toFixed(0);
|
|
965
|
+
const errMsg = `Model too large (${sizeMB}MB, limit ${limitMB}MB)`;
|
|
966
|
+
setError(errMsg);
|
|
967
|
+
setLoading(false);
|
|
968
|
+
onModelLoad?.({
|
|
969
|
+
supported: 0, missing: [...ARKIT_BLENDSHAPES], warnings: [errMsg],
|
|
970
|
+
modelStats: { vertexCount: 0, textureCount: 0, morphTargetCount: 0 },
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
}).catch(() => { });
|
|
974
|
+
}
|
|
880
975
|
const loader = new GLTFLoader();
|
|
881
976
|
loader.register((parser) => new VRMLoaderPlugin(parser));
|
|
882
977
|
loader.load(modelUrl, (gltf) => {
|
|
978
|
+
if (cancelled)
|
|
979
|
+
return;
|
|
883
980
|
const vrm = gltf.userData.vrm;
|
|
884
981
|
if (vrm) {
|
|
885
982
|
scene.add(vrm.scene);
|
|
886
983
|
vrmRef.current = vrm;
|
|
887
984
|
setLoading(false);
|
|
985
|
+
const report = buildCompatibilityReport(vrm);
|
|
986
|
+
if (report.warnings.length > 0) {
|
|
987
|
+
console.warn('[VRM] Compatibility warnings:', report.warnings);
|
|
988
|
+
}
|
|
989
|
+
onModelLoad?.(report);
|
|
888
990
|
}
|
|
889
991
|
}, () => { }, (err) => {
|
|
890
|
-
|
|
891
|
-
|
|
992
|
+
if (cancelled)
|
|
993
|
+
return;
|
|
994
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
995
|
+
const detail = message.includes('404') ? `Model not found: ${modelUrl}`
|
|
996
|
+
: message.includes('NetworkError') || message.includes('Failed to fetch')
|
|
997
|
+
? `Network error loading model: ${modelUrl}`
|
|
998
|
+
: `Failed to load VRM model: ${message}`;
|
|
999
|
+
console.error('[VRM] Error loading model:', detail);
|
|
1000
|
+
setError(detail);
|
|
892
1001
|
setLoading(false);
|
|
893
1002
|
});
|
|
894
1003
|
const clock = new THREE.Clock();
|
|
@@ -949,13 +1058,14 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
|
|
|
949
1058
|
};
|
|
950
1059
|
animate();
|
|
951
1060
|
return () => {
|
|
1061
|
+
cancelled = true;
|
|
952
1062
|
cancelAnimationFrame(animationFrameRef.current);
|
|
953
1063
|
renderer.dispose();
|
|
954
1064
|
if (container && renderer.domElement) {
|
|
955
1065
|
container.removeChild(renderer.domElement);
|
|
956
1066
|
}
|
|
957
1067
|
};
|
|
958
|
-
}, [width, height, modelUrl, backgroundColor]);
|
|
1068
|
+
}, [width, height, modelUrl, backgroundColor, maxModelSize, onModelLoad]);
|
|
959
1069
|
useEffect(() => {
|
|
960
1070
|
if (vrmRef.current && blendshapes.length > 0) {
|
|
961
1071
|
applyBlendshapes(vrmRef.current, blendshapes);
|
package/dist/react.js
CHANGED
|
@@ -45,6 +45,12 @@ const ARKIT_BLENDSHAPES = [
|
|
|
45
45
|
'browDownLeft', 'browDownRight', 'browInnerUp', 'browOuterUpLeft', 'browOuterUpRight'
|
|
46
46
|
];
|
|
47
47
|
const BLENDSHAPE_COUNT = 52;
|
|
48
|
+
const CRITICAL_BLENDSHAPES = [
|
|
49
|
+
'jawOpen', 'eyeBlinkLeft', 'eyeBlinkRight',
|
|
50
|
+
];
|
|
51
|
+
const DEFAULT_BLENDSHAPE_MAP = Object.fromEntries(ARKIT_BLENDSHAPES.map(name => [name, name]));
|
|
52
|
+
({
|
|
53
|
+
...DEFAULT_BLENDSHAPE_MAP});
|
|
48
54
|
|
|
49
55
|
const IDX_JAW_OPEN = ARKIT_BLENDSHAPES.indexOf('jawOpen');
|
|
50
56
|
const IDX_MOUTH_FUNNEL = ARKIT_BLENDSHAPES.indexOf('mouthFunnel');
|
|
@@ -789,6 +795,12 @@ function useAStackCSR(options) {
|
|
|
789
795
|
clientRef.current.sendText(message);
|
|
790
796
|
}
|
|
791
797
|
}, []);
|
|
798
|
+
const characterConfig = react.useMemo(() => ({
|
|
799
|
+
...(options.character?.modelUrl && { modelUrl: options.character.modelUrl }),
|
|
800
|
+
...(options.character?.blendshapeMap && { blendshapeMap: options.character.blendshapeMap }),
|
|
801
|
+
...(options.character?.onModelLoad && { onModelLoad: options.character.onModelLoad }),
|
|
802
|
+
...(options.character?.maxModelSize !== undefined && { maxModelSize: options.character.maxModelSize }),
|
|
803
|
+
}), [options.character?.modelUrl, options.character?.blendshapeMap, options.character?.onModelLoad, options.character?.maxModelSize]);
|
|
792
804
|
return {
|
|
793
805
|
client: clientRef.current,
|
|
794
806
|
isConnected,
|
|
@@ -797,6 +809,7 @@ function useAStackCSR(options) {
|
|
|
797
809
|
transcript,
|
|
798
810
|
response,
|
|
799
811
|
error,
|
|
812
|
+
characterConfig,
|
|
800
813
|
connect,
|
|
801
814
|
disconnect,
|
|
802
815
|
startCall,
|
|
@@ -805,7 +818,67 @@ function useAStackCSR(options) {
|
|
|
805
818
|
};
|
|
806
819
|
}
|
|
807
820
|
|
|
808
|
-
|
|
821
|
+
const DEFAULT_MAX_MODEL_SIZE = 30 * 1024 * 1024; // 30MB
|
|
822
|
+
const HIGH_VERTEX_THRESHOLD = 100000;
|
|
823
|
+
function buildCompatibilityReport(vrm) {
|
|
824
|
+
const foundNames = new Set();
|
|
825
|
+
let vertexCount = 0;
|
|
826
|
+
let textureCount = 0;
|
|
827
|
+
let morphTargetCount = 0;
|
|
828
|
+
vrm.scene.traverse((obj) => {
|
|
829
|
+
if (obj.isMesh) {
|
|
830
|
+
const mesh = obj;
|
|
831
|
+
const geo = mesh.geometry;
|
|
832
|
+
if (geo)
|
|
833
|
+
vertexCount += geo.attributes.position?.count || 0;
|
|
834
|
+
const material = mesh.material;
|
|
835
|
+
const materials = Array.isArray(material) ? material : [material];
|
|
836
|
+
for (const mat of materials) {
|
|
837
|
+
if (mat && 'map' in mat && mat.map)
|
|
838
|
+
textureCount++;
|
|
839
|
+
}
|
|
840
|
+
const morphDict = mesh.morphTargetDictionary;
|
|
841
|
+
if (morphDict) {
|
|
842
|
+
morphTargetCount += Object.keys(morphDict).length;
|
|
843
|
+
for (const name of Object.keys(morphDict)) {
|
|
844
|
+
foundNames.add(name);
|
|
845
|
+
foundNames.add(name.toLowerCase());
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
if (vrm.expressionManager) {
|
|
851
|
+
for (const exp of vrm.expressionManager.expressions) {
|
|
852
|
+
foundNames.add(exp.expressionName);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
const supported = [];
|
|
856
|
+
const missing = [];
|
|
857
|
+
for (const name of ARKIT_BLENDSHAPES) {
|
|
858
|
+
if (foundNames.has(name) || foundNames.has(name.toLowerCase())) {
|
|
859
|
+
supported.push(name);
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
missing.push(name);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
const warnings = [];
|
|
866
|
+
for (const name of CRITICAL_BLENDSHAPES) {
|
|
867
|
+
if (missing.includes(name)) {
|
|
868
|
+
warnings.push(`Missing critical blendshape: ${name}`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
if (vertexCount > HIGH_VERTEX_THRESHOLD) {
|
|
872
|
+
warnings.push(`High vertex count (${vertexCount.toLocaleString()}) may impact performance`);
|
|
873
|
+
}
|
|
874
|
+
return {
|
|
875
|
+
supported: supported.length,
|
|
876
|
+
missing,
|
|
877
|
+
warnings,
|
|
878
|
+
modelStats: { vertexCount, textureCount, morphTargetCount },
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models/avatar.vrm', backgroundColor = 0x1a1a2e, blendshapeMap, onModelLoad, maxModelSize = DEFAULT_MAX_MODEL_SIZE, }) {
|
|
809
882
|
const containerRef = react.useRef(null);
|
|
810
883
|
const rendererRef = react.useRef(null);
|
|
811
884
|
const sceneRef = react.useRef(null);
|
|
@@ -852,7 +925,8 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
|
|
|
852
925
|
const morphInfluences = mesh.morphTargetInfluences;
|
|
853
926
|
if (morphDict && morphInfluences) {
|
|
854
927
|
ARKIT_BLENDSHAPES.forEach((name, i) => {
|
|
855
|
-
const
|
|
928
|
+
const mapped = blendshapeMap?.[name] ?? name;
|
|
929
|
+
const idx = morphDict[mapped] ?? morphDict[mapped.toLowerCase()];
|
|
856
930
|
if (idx !== undefined) {
|
|
857
931
|
morphInfluences[idx] = shapes[i] || 0;
|
|
858
932
|
}
|
|
@@ -860,7 +934,7 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
|
|
|
860
934
|
}
|
|
861
935
|
}
|
|
862
936
|
});
|
|
863
|
-
}, []);
|
|
937
|
+
}, [blendshapeMap]);
|
|
864
938
|
react.useEffect(() => {
|
|
865
939
|
const container = containerRef.current;
|
|
866
940
|
if (!container)
|
|
@@ -898,18 +972,53 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
|
|
|
898
972
|
const backLight = new THREE__namespace.DirectionalLight(0x4a9eff, 0.3);
|
|
899
973
|
backLight.position.set(-1, 1, -1);
|
|
900
974
|
scene.add(backLight);
|
|
975
|
+
let cancelled = false;
|
|
976
|
+
// Size guard: fire HEAD check in parallel — if model is too large, cancel before render
|
|
977
|
+
if (maxModelSize > 0) {
|
|
978
|
+
fetch(modelUrl, { method: 'HEAD' }).then(res => {
|
|
979
|
+
if (cancelled)
|
|
980
|
+
return;
|
|
981
|
+
const contentLength = res.headers.get('content-length');
|
|
982
|
+
if (contentLength && parseInt(contentLength, 10) > maxModelSize) {
|
|
983
|
+
cancelled = true;
|
|
984
|
+
const sizeMB = (parseInt(contentLength, 10) / 1024 / 1024).toFixed(1);
|
|
985
|
+
const limitMB = (maxModelSize / 1024 / 1024).toFixed(0);
|
|
986
|
+
const errMsg = `Model too large (${sizeMB}MB, limit ${limitMB}MB)`;
|
|
987
|
+
setError(errMsg);
|
|
988
|
+
setLoading(false);
|
|
989
|
+
onModelLoad?.({
|
|
990
|
+
supported: 0, missing: [...ARKIT_BLENDSHAPES], warnings: [errMsg],
|
|
991
|
+
modelStats: { vertexCount: 0, textureCount: 0, morphTargetCount: 0 },
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}).catch(() => { });
|
|
995
|
+
}
|
|
901
996
|
const loader = new GLTFLoader_js.GLTFLoader();
|
|
902
997
|
loader.register((parser) => new threeVrm.VRMLoaderPlugin(parser));
|
|
903
998
|
loader.load(modelUrl, (gltf) => {
|
|
999
|
+
if (cancelled)
|
|
1000
|
+
return;
|
|
904
1001
|
const vrm = gltf.userData.vrm;
|
|
905
1002
|
if (vrm) {
|
|
906
1003
|
scene.add(vrm.scene);
|
|
907
1004
|
vrmRef.current = vrm;
|
|
908
1005
|
setLoading(false);
|
|
1006
|
+
const report = buildCompatibilityReport(vrm);
|
|
1007
|
+
if (report.warnings.length > 0) {
|
|
1008
|
+
console.warn('[VRM] Compatibility warnings:', report.warnings);
|
|
1009
|
+
}
|
|
1010
|
+
onModelLoad?.(report);
|
|
909
1011
|
}
|
|
910
1012
|
}, () => { }, (err) => {
|
|
911
|
-
|
|
912
|
-
|
|
1013
|
+
if (cancelled)
|
|
1014
|
+
return;
|
|
1015
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1016
|
+
const detail = message.includes('404') ? `Model not found: ${modelUrl}`
|
|
1017
|
+
: message.includes('NetworkError') || message.includes('Failed to fetch')
|
|
1018
|
+
? `Network error loading model: ${modelUrl}`
|
|
1019
|
+
: `Failed to load VRM model: ${message}`;
|
|
1020
|
+
console.error('[VRM] Error loading model:', detail);
|
|
1021
|
+
setError(detail);
|
|
913
1022
|
setLoading(false);
|
|
914
1023
|
});
|
|
915
1024
|
const clock = new THREE__namespace.Clock();
|
|
@@ -970,13 +1079,14 @@ function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models
|
|
|
970
1079
|
};
|
|
971
1080
|
animate();
|
|
972
1081
|
return () => {
|
|
1082
|
+
cancelled = true;
|
|
973
1083
|
cancelAnimationFrame(animationFrameRef.current);
|
|
974
1084
|
renderer.dispose();
|
|
975
1085
|
if (container && renderer.domElement) {
|
|
976
1086
|
container.removeChild(renderer.domElement);
|
|
977
1087
|
}
|
|
978
1088
|
};
|
|
979
|
-
}, [width, height, modelUrl, backgroundColor]);
|
|
1089
|
+
}, [width, height, modelUrl, backgroundColor, maxModelSize, onModelLoad]);
|
|
980
1090
|
react.useEffect(() => {
|
|
981
1091
|
if (vrmRef.current && blendshapes.length > 0) {
|
|
982
1092
|
applyBlendshapes(vrmRef.current, blendshapes);
|