@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 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>;
@@ -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');
@@ -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
- function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models/avatar.vrm', backgroundColor = 0x1a1a2e }) {
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 idx = morphDict[name] ?? morphDict[name.toLowerCase()];
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
- console.error('[VRM] Error loading model:', err);
891
- setError('Failed to load VRM model');
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
- function VRMAvatar({ blendshapes, width = 400, height = 400, modelUrl = '/models/avatar.vrm', backgroundColor = 0x1a1a2e }) {
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 idx = morphDict[name] ?? morphDict[name.toLowerCase()];
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
- console.error('[VRM] Error loading model:', err);
912
- setError('Failed to load VRM model');
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aether-stack-dev/client-sdk",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "type": "module",
5
5
  "description": "JavaScript/TypeScript SDK for AStack video-to-video AI conversations",
6
6
  "main": "dist/index.js",