talking-head-studio 0.2.6 → 0.2.7
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 +8 -7
- package/dist/TalkingHead.d.ts +0 -1
- package/dist/TalkingHead.js +31 -29
- package/dist/TalkingHead.web.d.ts +1 -3
- package/dist/TalkingHead.web.js +72 -43
- package/dist/appearance/apply.d.ts +0 -1
- package/dist/appearance/apply.js +8 -5
- package/dist/appearance/index.d.ts +0 -1
- package/dist/appearance/index.js +9 -3
- package/dist/appearance/matchers.d.ts +0 -1
- package/dist/appearance/matchers.js +4 -1
- package/dist/appearance/schema.d.ts +0 -1
- package/dist/appearance/schema.js +4 -1
- package/dist/editor/AvatarCanvas.d.ts +1 -2
- package/dist/editor/AvatarCanvas.js +44 -60
- package/dist/editor/AvatarCanvasErrorBoundary.d.ts +1 -2
- package/dist/editor/AvatarCanvasErrorBoundary.js +9 -14
- package/dist/editor/AvatarModel.d.ts +1 -2
- package/dist/editor/AvatarModel.js +17 -13
- package/dist/editor/RigidAccessory.d.ts +1 -2
- package/dist/editor/RigidAccessory.js +19 -17
- package/dist/editor/SkinnedClothing.d.ts +1 -2
- package/dist/editor/SkinnedClothing.js +46 -9
- package/dist/editor/index.d.ts +0 -1
- package/dist/editor/index.js +11 -4
- package/dist/editor/types.d.ts +0 -1
- package/dist/editor/types.js +2 -1
- package/dist/html.d.ts +0 -1
- package/dist/html.js +8 -5
- package/dist/index.d.ts +0 -1
- package/dist/index.js +20 -2
- package/dist/index.web.d.ts +0 -1
- package/dist/index.web.js +20 -2
- package/dist/sketchfab/api.d.ts +0 -1
- package/dist/sketchfab/api.js +10 -4
- package/dist/sketchfab/categories.d.ts +0 -1
- package/dist/sketchfab/categories.js +5 -2
- package/dist/sketchfab/index.d.ts +0 -1
- package/dist/sketchfab/index.js +13 -3
- package/dist/sketchfab/types.d.ts +0 -1
- package/dist/sketchfab/types.js +2 -1
- package/dist/sketchfab/useSketchfabSearch.d.ts +0 -1
- package/dist/sketchfab/useSketchfabSearch.js +20 -17
- package/dist/voice/convertToWav.d.ts +0 -1
- package/dist/voice/convertToWav.js +4 -1
- package/dist/voice/index.d.ts +0 -1
- package/dist/voice/index.js +9 -3
- package/dist/voice/useAudioPlayer.d.ts +0 -1
- package/dist/voice/useAudioPlayer.js +7 -4
- package/dist/voice/useAudioRecording.d.ts +0 -1
- package/dist/voice/useAudioRecording.js +20 -17
- package/package.json +10 -8
- package/dist/TalkingHead.d.ts.map +0 -1
- package/dist/TalkingHead.web.d.ts.map +0 -1
- package/dist/__tests__/TalkingHead.test.d.ts +0 -2
- package/dist/__tests__/TalkingHead.test.d.ts.map +0 -1
- package/dist/__tests__/TalkingHead.test.js +0 -23
- package/dist/__tests__/sketchfab.test.d.ts +0 -2
- package/dist/__tests__/sketchfab.test.d.ts.map +0 -1
- package/dist/__tests__/sketchfab.test.js +0 -21
- package/dist/appearance/apply.d.ts.map +0 -1
- package/dist/appearance/index.d.ts.map +0 -1
- package/dist/appearance/matchers.d.ts.map +0 -1
- package/dist/appearance/schema.d.ts.map +0 -1
- package/dist/editor/AvatarCanvas.d.ts.map +0 -1
- package/dist/editor/AvatarCanvasErrorBoundary.d.ts.map +0 -1
- package/dist/editor/AvatarModel.d.ts.map +0 -1
- package/dist/editor/RigidAccessory.d.ts.map +0 -1
- package/dist/editor/SkinnedClothing.d.ts.map +0 -1
- package/dist/editor/index.d.ts.map +0 -1
- package/dist/editor/types.d.ts.map +0 -1
- package/dist/html.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.web.d.ts.map +0 -1
- package/dist/sketchfab/api.d.ts.map +0 -1
- package/dist/sketchfab/categories.d.ts.map +0 -1
- package/dist/sketchfab/index.d.ts.map +0 -1
- package/dist/sketchfab/types.d.ts.map +0 -1
- package/dist/sketchfab/useSketchfabSearch.d.ts.map +0 -1
- package/dist/voice/convertToWav.d.ts.map +0 -1
- package/dist/voice/index.d.ts.map +0 -1
- package/dist/voice/useAudioPlayer.d.ts.map +0 -1
- package/dist/voice/useAudioRecording.d.ts.map +0 -1
- package/src/TalkingHead.tsx +0 -276
- package/src/TalkingHead.web.tsx +0 -220
- package/src/__tests__/TalkingHead.test.tsx +0 -32
- package/src/__tests__/sketchfab.test.ts +0 -24
- package/src/appearance/apply.ts +0 -94
- package/src/appearance/index.ts +0 -4
- package/src/appearance/matchers.ts +0 -43
- package/src/appearance/schema.ts +0 -35
- package/src/editor/AvatarCanvas.tsx +0 -167
- package/src/editor/AvatarCanvasErrorBoundary.tsx +0 -64
- package/src/editor/AvatarModel.tsx +0 -49
- package/src/editor/RigidAccessory.tsx +0 -130
- package/src/editor/SkinnedClothing.tsx +0 -114
- package/src/editor/index.ts +0 -5
- package/src/editor/r3f-shim.d.ts +0 -34
- package/src/editor/types.ts +0 -30
- package/src/html.ts +0 -678
- package/src/index.ts +0 -11
- package/src/index.web.ts +0 -8
- package/src/sketchfab/api.ts +0 -82
- package/src/sketchfab/categories.ts +0 -127
- package/src/sketchfab/index.ts +0 -6
- package/src/sketchfab/types.ts +0 -40
- package/src/sketchfab/useSketchfabSearch.ts +0 -110
- package/src/voice/convertToWav.ts +0 -87
- package/src/voice/index.ts +0 -7
- package/src/voice/useAudioPlayer.ts +0 -78
- package/src/voice/useAudioRecording.ts +0 -207
package/src/TalkingHead.web.tsx
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
forwardRef,
|
|
3
|
-
useCallback,
|
|
4
|
-
useEffect,
|
|
5
|
-
useImperativeHandle,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
} from 'react';
|
|
9
|
-
import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native';
|
|
10
|
-
import { buildAvatarHtml } from './html';
|
|
11
|
-
import type {
|
|
12
|
-
TalkingHeadVisemeCue,
|
|
13
|
-
TalkingHeadVisemeSchedule,
|
|
14
|
-
} from './TalkingHead';
|
|
15
|
-
|
|
16
|
-
export type { TalkingHeadVisemeCue, TalkingHeadVisemeSchedule };
|
|
17
|
-
|
|
18
|
-
export type TalkingHeadMood =
|
|
19
|
-
| 'neutral'
|
|
20
|
-
| 'happy'
|
|
21
|
-
| 'sad'
|
|
22
|
-
| 'angry'
|
|
23
|
-
| 'excited'
|
|
24
|
-
| 'thinking'
|
|
25
|
-
| 'concerned'
|
|
26
|
-
| 'surprised';
|
|
27
|
-
|
|
28
|
-
export interface TalkingHeadAccessory {
|
|
29
|
-
id: string;
|
|
30
|
-
url: string;
|
|
31
|
-
bone: string;
|
|
32
|
-
position: [number, number, number];
|
|
33
|
-
rotation: [number, number, number];
|
|
34
|
-
scale: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface TalkingHeadProps {
|
|
38
|
-
avatarUrl: string;
|
|
39
|
-
authToken?: string | null;
|
|
40
|
-
mood?: TalkingHeadMood;
|
|
41
|
-
cameraView?: 'head' | 'upper' | 'full';
|
|
42
|
-
cameraDistance?: number;
|
|
43
|
-
hairColor?: string;
|
|
44
|
-
skinColor?: string;
|
|
45
|
-
eyeColor?: string;
|
|
46
|
-
accessories?: TalkingHeadAccessory[];
|
|
47
|
-
onReady?: () => void;
|
|
48
|
-
onError?: (message: string) => void;
|
|
49
|
-
style?: StyleProp<ViewStyle>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface TalkingHeadRef {
|
|
53
|
-
sendAmplitude: (amplitude: number) => void;
|
|
54
|
-
scheduleVisemes: (schedule: TalkingHeadVisemeSchedule) => void;
|
|
55
|
-
clearVisemes: () => void;
|
|
56
|
-
setMood: (mood: TalkingHeadMood) => void;
|
|
57
|
-
setHairColor: (color: string) => void;
|
|
58
|
-
setSkinColor: (color: string) => void;
|
|
59
|
-
setEyeColor: (color: string) => void;
|
|
60
|
-
setAccessories: (accessories: TalkingHeadAccessory[]) => void;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export const TalkingHead = forwardRef<TalkingHeadRef, TalkingHeadProps>(
|
|
64
|
-
(
|
|
65
|
-
{
|
|
66
|
-
avatarUrl,
|
|
67
|
-
authToken,
|
|
68
|
-
mood = 'neutral',
|
|
69
|
-
cameraView = 'upper',
|
|
70
|
-
cameraDistance = -0.5,
|
|
71
|
-
hairColor,
|
|
72
|
-
skinColor,
|
|
73
|
-
eyeColor,
|
|
74
|
-
accessories,
|
|
75
|
-
onReady,
|
|
76
|
-
onError,
|
|
77
|
-
style,
|
|
78
|
-
},
|
|
79
|
-
ref,
|
|
80
|
-
) => {
|
|
81
|
-
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
82
|
-
const readyRef = useRef(false);
|
|
83
|
-
const accessoriesRef = useRef(accessories);
|
|
84
|
-
|
|
85
|
-
useEffect(() => {
|
|
86
|
-
accessoriesRef.current = accessories;
|
|
87
|
-
}, [accessories]);
|
|
88
|
-
|
|
89
|
-
const post = useCallback((msg: object) => {
|
|
90
|
-
iframeRef.current?.contentWindow?.postMessage(JSON.stringify(msg), '*');
|
|
91
|
-
}, []);
|
|
92
|
-
|
|
93
|
-
useImperativeHandle(
|
|
94
|
-
ref,
|
|
95
|
-
() => ({
|
|
96
|
-
sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
|
|
97
|
-
scheduleVisemes: (schedule) => post({ type: 'schedule_visemes', schedule }),
|
|
98
|
-
clearVisemes: () => post({ type: 'clear_visemes' }),
|
|
99
|
-
setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
|
|
100
|
-
setHairColor: (color) => post({ type: 'hair_color', value: color }),
|
|
101
|
-
setSkinColor: (color) => post({ type: 'skin_color', value: color }),
|
|
102
|
-
setEyeColor: (color) => post({ type: 'eye_color', value: color }),
|
|
103
|
-
setAccessories: (newAccessories) =>
|
|
104
|
-
post({ type: 'set_accessories', accessories: newAccessories }),
|
|
105
|
-
}),
|
|
106
|
-
[post],
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
if (readyRef.current) post({ type: 'mood', value: mood });
|
|
111
|
-
}, [mood, post]);
|
|
112
|
-
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
if (accessories && readyRef.current) {
|
|
115
|
-
post({ type: 'set_accessories', accessories });
|
|
116
|
-
}
|
|
117
|
-
}, [accessories, post]);
|
|
118
|
-
|
|
119
|
-
useEffect(() => {
|
|
120
|
-
if (hairColor && readyRef.current) post({ type: 'hair_color', value: hairColor });
|
|
121
|
-
}, [hairColor, post]);
|
|
122
|
-
|
|
123
|
-
useEffect(() => {
|
|
124
|
-
if (skinColor && readyRef.current) post({ type: 'skin_color', value: skinColor });
|
|
125
|
-
}, [skinColor, post]);
|
|
126
|
-
|
|
127
|
-
useEffect(() => {
|
|
128
|
-
if (eyeColor && readyRef.current) post({ type: 'eye_color', value: eyeColor });
|
|
129
|
-
}, [eyeColor, post]);
|
|
130
|
-
|
|
131
|
-
const [initialMood] = React.useState(mood);
|
|
132
|
-
const [initialHairColor] = React.useState(hairColor);
|
|
133
|
-
const [initialSkinColor] = React.useState(skinColor);
|
|
134
|
-
const [initialEyeColor] = React.useState(eyeColor);
|
|
135
|
-
|
|
136
|
-
const html = useMemo(
|
|
137
|
-
() =>
|
|
138
|
-
buildAvatarHtml({
|
|
139
|
-
avatarUrl,
|
|
140
|
-
authToken,
|
|
141
|
-
mood: initialMood,
|
|
142
|
-
cameraView,
|
|
143
|
-
cameraDistance,
|
|
144
|
-
initialHairColor,
|
|
145
|
-
initialSkinColor,
|
|
146
|
-
initialEyeColor,
|
|
147
|
-
}),
|
|
148
|
-
[
|
|
149
|
-
avatarUrl,
|
|
150
|
-
authToken,
|
|
151
|
-
cameraView,
|
|
152
|
-
cameraDistance,
|
|
153
|
-
initialMood,
|
|
154
|
-
initialHairColor,
|
|
155
|
-
initialSkinColor,
|
|
156
|
-
initialEyeColor,
|
|
157
|
-
],
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
// The HTML references window.ReactNativeWebView.postMessage — we need to
|
|
161
|
-
// inject that shim so messages come back to us via window.addEventListener.
|
|
162
|
-
const srcdoc = useMemo(() => {
|
|
163
|
-
const shim = `<script>window.ReactNativeWebView = { postMessage: function(d) { window.parent.postMessage(d, '*'); } };</script>`;
|
|
164
|
-
// Insert the shim right after <head> so it's available before the module script runs
|
|
165
|
-
return html.replace('<head>', '<head>' + shim);
|
|
166
|
-
}, [html]);
|
|
167
|
-
|
|
168
|
-
useEffect(() => {
|
|
169
|
-
const onMessage = (event: MessageEvent) => {
|
|
170
|
-
// Only accept messages from our iframe
|
|
171
|
-
if (iframeRef.current && event.source !== iframeRef.current.contentWindow) return;
|
|
172
|
-
try {
|
|
173
|
-
const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
174
|
-
if (msg.type === 'ready') {
|
|
175
|
-
readyRef.current = true;
|
|
176
|
-
if (accessoriesRef.current?.length) {
|
|
177
|
-
post({ type: 'set_accessories', accessories: accessoriesRef.current });
|
|
178
|
-
}
|
|
179
|
-
onReady?.();
|
|
180
|
-
} else if (msg.type === 'error') {
|
|
181
|
-
onError?.(msg.message);
|
|
182
|
-
} else if (msg.type === 'log') {
|
|
183
|
-
console.log('[TalkingHead]', msg.message);
|
|
184
|
-
}
|
|
185
|
-
} catch {
|
|
186
|
-
// ignore non-JSON messages
|
|
187
|
-
}
|
|
188
|
-
};
|
|
189
|
-
window.addEventListener('message', onMessage);
|
|
190
|
-
return () => window.removeEventListener('message', onMessage);
|
|
191
|
-
}, [onReady, onError, post]);
|
|
192
|
-
|
|
193
|
-
return (
|
|
194
|
-
<View style={[styles.container, style]}>
|
|
195
|
-
<iframe
|
|
196
|
-
ref={iframeRef as any}
|
|
197
|
-
srcDoc={srcdoc}
|
|
198
|
-
style={{
|
|
199
|
-
width: '100%',
|
|
200
|
-
height: '100%',
|
|
201
|
-
border: 'none',
|
|
202
|
-
backgroundColor: 'transparent',
|
|
203
|
-
}}
|
|
204
|
-
sandbox="allow-scripts allow-same-origin"
|
|
205
|
-
title="TalkingHead Avatar"
|
|
206
|
-
/>
|
|
207
|
-
</View>
|
|
208
|
-
);
|
|
209
|
-
},
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
TalkingHead.displayName = 'TalkingHead';
|
|
213
|
-
|
|
214
|
-
const styles = StyleSheet.create({
|
|
215
|
-
container: {
|
|
216
|
-
overflow: 'hidden',
|
|
217
|
-
borderRadius: 12,
|
|
218
|
-
backgroundColor: 'transparent',
|
|
219
|
-
},
|
|
220
|
-
});
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { render } from '@testing-library/react-native';
|
|
3
|
-
import { TalkingHead } from '../TalkingHead';
|
|
4
|
-
|
|
5
|
-
// Mock react-native-webview
|
|
6
|
-
jest.mock('react-native-webview', () => {
|
|
7
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
8
|
-
const React = require('react');
|
|
9
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
10
|
-
const { View } = require('react-native');
|
|
11
|
-
|
|
12
|
-
const MockWebView = React.forwardRef((props: { testID?: string }, ref: React.Ref<unknown>) => {
|
|
13
|
-
React.useImperativeHandle(ref, () => ({
|
|
14
|
-
postMessage: jest.fn(),
|
|
15
|
-
}));
|
|
16
|
-
return <View {...props} />;
|
|
17
|
-
});
|
|
18
|
-
MockWebView.displayName = 'MockWebView';
|
|
19
|
-
|
|
20
|
-
return { WebView: MockWebView };
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe('TalkingHead', () => {
|
|
24
|
-
it('renders without crashing', () => {
|
|
25
|
-
render(
|
|
26
|
-
<TalkingHead
|
|
27
|
-
avatarUrl="https://example.com/avatar.glb"
|
|
28
|
-
style={{ flex: 1 }}
|
|
29
|
-
/>
|
|
30
|
-
);
|
|
31
|
-
});
|
|
32
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { ACCESSORY_CATEGORIES } from '../sketchfab';
|
|
2
|
-
|
|
3
|
-
describe('ACCESSORY_CATEGORIES', () => {
|
|
4
|
-
it('includes the full accessory category set used by the app browsers', () => {
|
|
5
|
-
expect(ACCESSORY_CATEGORIES.map((category) => category.id)).toEqual(
|
|
6
|
-
expect.arrayContaining([
|
|
7
|
-
'hair',
|
|
8
|
-
'hat',
|
|
9
|
-
'glasses',
|
|
10
|
-
'necklace',
|
|
11
|
-
'handheld',
|
|
12
|
-
'glove',
|
|
13
|
-
'wings',
|
|
14
|
-
'tail',
|
|
15
|
-
'cape',
|
|
16
|
-
'belt',
|
|
17
|
-
'shoulder_pad',
|
|
18
|
-
'top',
|
|
19
|
-
'bottom',
|
|
20
|
-
'footwear',
|
|
21
|
-
]),
|
|
22
|
-
);
|
|
23
|
-
});
|
|
24
|
-
});
|
package/src/appearance/apply.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { pickTargetForMaterialName } from './matchers';
|
|
2
|
-
import { type AvatarAppearance, normalizeAppearance } from './schema';
|
|
3
|
-
|
|
4
|
-
type MaterialLike = {
|
|
5
|
-
name?: string;
|
|
6
|
-
color?: {
|
|
7
|
-
set: (value: string) => void;
|
|
8
|
-
};
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
type MeshLike = {
|
|
12
|
-
isMesh?: boolean;
|
|
13
|
-
material?: MaterialLike | MaterialLike[];
|
|
14
|
-
geometry?: {
|
|
15
|
-
attributes?: {
|
|
16
|
-
position?: { count?: number };
|
|
17
|
-
};
|
|
18
|
-
};
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
type Object3DLike = {
|
|
22
|
-
traverse?: (visitor: (object: unknown) => void) => void;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
function asMaterialArray(material: MeshLike['material']): MaterialLike[] {
|
|
26
|
-
if (!material) {
|
|
27
|
-
return [];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return Array.isArray(material) ? material : [material];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function getVertexCount(mesh: MeshLike): number {
|
|
34
|
-
return mesh.geometry?.attributes?.position?.count ?? 0;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function applyAppearanceToObject3D(
|
|
38
|
-
object3d: Object3DLike,
|
|
39
|
-
appearance: AvatarAppearance,
|
|
40
|
-
): void {
|
|
41
|
-
if (typeof object3d.traverse !== 'function') {
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const normalizedAppearance = normalizeAppearance(appearance);
|
|
46
|
-
|
|
47
|
-
// First pass: apply name-matched colors and track whether skin was matched,
|
|
48
|
-
// also track the largest mesh for fallback skin coloring.
|
|
49
|
-
let skinMatched = false;
|
|
50
|
-
let largestVertexCount = 0;
|
|
51
|
-
let largestMeshMaterial: MaterialLike | null = null;
|
|
52
|
-
|
|
53
|
-
object3d.traverse((object) => {
|
|
54
|
-
const mesh = object as MeshLike;
|
|
55
|
-
if (!mesh?.isMesh) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const vertexCount = getVertexCount(mesh);
|
|
60
|
-
|
|
61
|
-
for (const material of asMaterialArray(mesh.material)) {
|
|
62
|
-
if (!material || typeof material.name !== 'string') {
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const target = pickTargetForMaterialName(material.name);
|
|
67
|
-
if (!target) {
|
|
68
|
-
// Track the largest unmatched mesh as a skin fallback candidate
|
|
69
|
-
if (vertexCount > largestVertexCount && material.color && typeof material.color.set === 'function') {
|
|
70
|
-
largestVertexCount = vertexCount;
|
|
71
|
-
largestMeshMaterial = material;
|
|
72
|
-
}
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (target === 'skinColor') {
|
|
77
|
-
skinMatched = true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const color = normalizedAppearance[target];
|
|
81
|
-
if (!color || !material.color || typeof material.color.set !== 'function') {
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
material.color.set(color);
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
// Second pass: if no skin material was found by name, apply skin color to
|
|
90
|
-
// the largest mesh — skin is the largest organ after all.
|
|
91
|
-
if (!skinMatched && largestMeshMaterial && normalizedAppearance.skinColor) {
|
|
92
|
-
(largestMeshMaterial as MaterialLike).color!.set(normalizedAppearance.skinColor);
|
|
93
|
-
}
|
|
94
|
-
}
|
package/src/appearance/index.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
export type AppearanceTarget = 'hairColor' | 'skinColor' | 'eyeColor';
|
|
2
|
-
|
|
3
|
-
function tokenizeMaterialName(name: string): string[] {
|
|
4
|
-
return name
|
|
5
|
-
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
6
|
-
.toLowerCase()
|
|
7
|
-
.split(/[^a-z\d]+/)
|
|
8
|
-
.filter(Boolean);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Exact token match: any token equals the candidate exactly
|
|
12
|
-
function hasToken(tokens: string[], candidates: string[]): boolean {
|
|
13
|
-
return candidates.some((candidate) => tokens.includes(candidate));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Prefix match: candidate appears at the START of a token.
|
|
17
|
-
// e.g. "eye" matches token "eyeball", "eyelid", "eyebrow" but NOT "chair" (c-hair).
|
|
18
|
-
// We intentionally avoid suffix/contains to prevent false positives like chair→hair.
|
|
19
|
-
function hasTokenPrefix(tokens: string[], candidates: string[]): boolean {
|
|
20
|
-
return candidates.some((candidate) =>
|
|
21
|
-
tokens.some((token) => token.startsWith(candidate) && token.length > candidate.length),
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function pickTargetForMaterialName(name: string): AppearanceTarget | null {
|
|
26
|
-
const tokens = tokenizeMaterialName(name);
|
|
27
|
-
|
|
28
|
-
if (hasToken(tokens, ['hair', 'fur']) || hasTokenPrefix(tokens, ['hair', 'fur'])) {
|
|
29
|
-
return 'hairColor';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (hasToken(tokens, ['skin', 'body', 'face']) || hasTokenPrefix(tokens, ['skin', 'body', 'face'])) {
|
|
33
|
-
return 'skinColor';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// "eye" prefix catches: eyeball, eyelid, eyebrow, eyelash
|
|
37
|
-
// "eye" suffix catches: lefteye, righteye
|
|
38
|
-
if (hasToken(tokens, ['eye', 'iris']) || hasTokenPrefix(tokens, ['eye', 'iris'])) {
|
|
39
|
-
return 'eyeColor';
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return null;
|
|
43
|
-
}
|
package/src/appearance/schema.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
export type AppearanceVersion = 1;
|
|
2
|
-
|
|
3
|
-
export interface AvatarAppearance {
|
|
4
|
-
version: AppearanceVersion;
|
|
5
|
-
hairColor?: string;
|
|
6
|
-
skinColor?: string;
|
|
7
|
-
eyeColor?: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const HEX_COLOR_PATTERN = /^#(?:[\dA-Fa-f]{3}|[\dA-Fa-f]{6})$/;
|
|
11
|
-
|
|
12
|
-
function normalizeHexColor(value: string): string {
|
|
13
|
-
if (!HEX_COLOR_PATTERN.test(value)) {
|
|
14
|
-
throw new Error(`Invalid color format: ${value}`);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const raw = value.slice(1);
|
|
18
|
-
const expanded =
|
|
19
|
-
raw.length === 3 ? `${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}` : raw;
|
|
20
|
-
|
|
21
|
-
return `#${expanded.toUpperCase()}`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function normalizeAppearance(input: AvatarAppearance): AvatarAppearance {
|
|
25
|
-
if (input.version !== 1) {
|
|
26
|
-
throw new Error(`Unsupported appearance version: ${input.version}`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
version: input.version,
|
|
31
|
-
hairColor: input.hairColor != null ? normalizeHexColor(input.hairColor) : undefined,
|
|
32
|
-
skinColor: input.skinColor != null ? normalizeHexColor(input.skinColor) : undefined,
|
|
33
|
-
eyeColor: input.eyeColor != null ? normalizeHexColor(input.eyeColor) : undefined,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
|
-
import { Environment, OrbitControls } from '@react-three/drei';
|
|
3
|
-
import { Canvas, useThree } from '@react-three/fiber';
|
|
4
|
-
import { Suspense, useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
-
import type { Vector3 } from 'three';
|
|
6
|
-
import type { AvatarAppearance } from '../appearance';
|
|
7
|
-
import { AvatarCanvasErrorBoundary } from './AvatarCanvasErrorBoundary';
|
|
8
|
-
import { AvatarModel } from './AvatarModel';
|
|
9
|
-
import { RigidAccessory } from './RigidAccessory';
|
|
10
|
-
import { SkinnedClothing } from './SkinnedClothing';
|
|
11
|
-
import type { AssetPlacement, EquippedAsset } from './types';
|
|
12
|
-
|
|
13
|
-
/** Captures the R3F scene object (which contains everything) and passes it up. */
|
|
14
|
-
function SceneRefCapture({ onSceneRef }: { onSceneRef: (scene: any) => void }) {
|
|
15
|
-
const { scene } = useThree();
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
onSceneRef(scene);
|
|
18
|
-
}, [scene, onSceneRef]);
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface AvatarCanvasProps {
|
|
23
|
-
avatarUrl: string;
|
|
24
|
-
appearance?: AvatarAppearance;
|
|
25
|
-
equipped?: EquippedAsset[];
|
|
26
|
-
placements?: Record<string, AssetPlacement>;
|
|
27
|
-
editingAssetId?: string | null;
|
|
28
|
-
onPlacementChange?: (assetId: string, placement: AssetPlacement) => void;
|
|
29
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
-
onSceneRef?: (scene: any) => void;
|
|
31
|
-
style?: React.CSSProperties;
|
|
32
|
-
className?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function AvatarCanvas({
|
|
36
|
-
avatarUrl,
|
|
37
|
-
appearance,
|
|
38
|
-
equipped = [],
|
|
39
|
-
placements = {},
|
|
40
|
-
editingAssetId = null,
|
|
41
|
-
onPlacementChange,
|
|
42
|
-
onSceneRef,
|
|
43
|
-
style,
|
|
44
|
-
className,
|
|
45
|
-
}: AvatarCanvasProps) {
|
|
46
|
-
const [skeleton, setSkeleton] = useState<any>(null);
|
|
47
|
-
const [avatarScene, setAvatarScene] = useState<any>(null);
|
|
48
|
-
const [cameraTarget, setCameraTarget] = useState<[number, number, number]>([0, 1, 0]);
|
|
49
|
-
const [cameraPosition, setCameraPosition] = useState<[number, number, number]>([0, 1.2, 2.5]);
|
|
50
|
-
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
51
|
-
|
|
52
|
-
const handleSkeletonReady = useCallback((skel: any) => {
|
|
53
|
-
setSkeleton(skel);
|
|
54
|
-
if (skel.bones.length > 0) {
|
|
55
|
-
const root = skel.bones[0].parent ?? skel.bones[0];
|
|
56
|
-
setAvatarScene(root);
|
|
57
|
-
}
|
|
58
|
-
}, []);
|
|
59
|
-
|
|
60
|
-
const handleBoundsReady = useCallback((center: Vector3, height: number) => {
|
|
61
|
-
const dist = Math.max(height * 1.4, 1.5);
|
|
62
|
-
setCameraTarget([center.x, center.y, center.z]);
|
|
63
|
-
setCameraPosition([center.x, center.y + height * 0.1, center.z + dist]);
|
|
64
|
-
}, []);
|
|
65
|
-
|
|
66
|
-
const equippedItems = equipped.filter(Boolean);
|
|
67
|
-
|
|
68
|
-
const wrapperClass = [
|
|
69
|
-
'w-full h-full overflow-hidden relative bg-[radial-gradient(ellipse_at_center,_rgba(30,20,60,0.6)_0%,_rgba(5,5,15,0.95)_100%)] [background-size:100%_100%]',
|
|
70
|
-
className,
|
|
71
|
-
]
|
|
72
|
-
.filter(Boolean)
|
|
73
|
-
.join(' ');
|
|
74
|
-
|
|
75
|
-
return (
|
|
76
|
-
<div className={wrapperClass} style={style}>
|
|
77
|
-
{/* Subtle grid floor */}
|
|
78
|
-
<div className="absolute inset-0 opacity-[0.04] pointer-events-none"
|
|
79
|
-
style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,0.3) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.3) 1px,transparent 1px)', backgroundSize: '60px 60px' }} />
|
|
80
|
-
{avatarUrl ? (
|
|
81
|
-
<AvatarCanvasErrorBoundary>
|
|
82
|
-
<Canvas
|
|
83
|
-
ref={canvasRef}
|
|
84
|
-
camera={{ position: cameraPosition, fov: 45 }}
|
|
85
|
-
style={{ width: '100%', height: '100%' }}
|
|
86
|
-
>
|
|
87
|
-
<Suspense fallback={null}>
|
|
88
|
-
{onSceneRef && <SceneRefCapture onSceneRef={onSceneRef} />}
|
|
89
|
-
<ambientLight intensity={0.6} />
|
|
90
|
-
<directionalLight position={[5, 5, 5]} intensity={0.8} />
|
|
91
|
-
<Environment preset="studio" />
|
|
92
|
-
<OrbitControls
|
|
93
|
-
target={cameraTarget}
|
|
94
|
-
minDistance={1}
|
|
95
|
-
maxDistance={10}
|
|
96
|
-
enablePan={false}
|
|
97
|
-
makeDefault
|
|
98
|
-
/>
|
|
99
|
-
|
|
100
|
-
<AvatarModel
|
|
101
|
-
url={avatarUrl}
|
|
102
|
-
appearance={appearance}
|
|
103
|
-
scale={1}
|
|
104
|
-
onSkeletonReady={handleSkeletonReady}
|
|
105
|
-
onBoundsReady={handleBoundsReady}
|
|
106
|
-
/>
|
|
107
|
-
|
|
108
|
-
{equippedItems.map((asset) => {
|
|
109
|
-
if (!asset) return null;
|
|
110
|
-
const llmPlacement = placements[asset.id];
|
|
111
|
-
const isEditing = editingAssetId === asset.id;
|
|
112
|
-
|
|
113
|
-
// If a placement is provided, always render as rigid attachment
|
|
114
|
-
if (llmPlacement) {
|
|
115
|
-
const bone = llmPlacement.bone ?? asset.attach_bone;
|
|
116
|
-
if (!bone) return null;
|
|
117
|
-
return (
|
|
118
|
-
<RigidAccessory
|
|
119
|
-
key={asset.id}
|
|
120
|
-
assetId={asset.id}
|
|
121
|
-
url={asset.url}
|
|
122
|
-
avatarScene={avatarScene}
|
|
123
|
-
attachBone={bone}
|
|
124
|
-
offsetPosition={llmPlacement.position}
|
|
125
|
-
offsetRotation={llmPlacement.rotation}
|
|
126
|
-
scale={llmPlacement.scale}
|
|
127
|
-
isEditing={isEditing}
|
|
128
|
-
onPlacementChange={onPlacementChange}
|
|
129
|
-
/>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (asset.type === 'skinned') {
|
|
134
|
-
return (
|
|
135
|
-
<SkinnedClothing key={asset.id} url={asset.url} avatarSkeleton={skeleton} />
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
if (asset.type === 'rigid') {
|
|
139
|
-
const bone = asset.attach_bone;
|
|
140
|
-
if (!bone) return null;
|
|
141
|
-
return (
|
|
142
|
-
<RigidAccessory
|
|
143
|
-
key={asset.id}
|
|
144
|
-
assetId={asset.id}
|
|
145
|
-
url={asset.url}
|
|
146
|
-
avatarScene={avatarScene}
|
|
147
|
-
attachBone={bone}
|
|
148
|
-
offsetPosition={asset.offset_position}
|
|
149
|
-
offsetRotation={asset.offset_rotation}
|
|
150
|
-
isEditing={isEditing}
|
|
151
|
-
onPlacementChange={onPlacementChange}
|
|
152
|
-
/>
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
return null;
|
|
156
|
-
})}
|
|
157
|
-
</Suspense>
|
|
158
|
-
</Canvas>
|
|
159
|
-
</AvatarCanvasErrorBoundary>
|
|
160
|
-
) : (
|
|
161
|
-
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
162
|
-
<p>Select a base avatar to get started</p>
|
|
163
|
-
</div>
|
|
164
|
-
)}
|
|
165
|
-
</div>
|
|
166
|
-
);
|
|
167
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
-
|
|
3
|
-
interface AvatarCanvasErrorBoundaryProps {
|
|
4
|
-
children: ReactNode;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
interface AvatarCanvasErrorBoundaryState {
|
|
8
|
-
hasError: boolean;
|
|
9
|
-
retryKey: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export class AvatarCanvasErrorBoundary extends Component<
|
|
13
|
-
AvatarCanvasErrorBoundaryProps,
|
|
14
|
-
AvatarCanvasErrorBoundaryState
|
|
15
|
-
> {
|
|
16
|
-
state: AvatarCanvasErrorBoundaryState = {
|
|
17
|
-
hasError: false,
|
|
18
|
-
retryKey: 0,
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
|
22
|
-
console.error('[AvatarCanvas] WebGL error', error, errorInfo.componentStack);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
static getDerivedStateFromError(): AvatarCanvasErrorBoundaryState {
|
|
26
|
-
return {
|
|
27
|
-
hasError: true,
|
|
28
|
-
retryKey: 0,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
private handleRetry = () => {
|
|
33
|
-
this.setState((prev) => ({
|
|
34
|
-
hasError: false,
|
|
35
|
-
retryKey: prev.retryKey + 1,
|
|
36
|
-
}));
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
render() {
|
|
40
|
-
if (this.state.hasError) {
|
|
41
|
-
return (
|
|
42
|
-
<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-4 text-center">
|
|
43
|
-
<p className="text-sm font-medium text-foreground">3D rendering unavailable</p>
|
|
44
|
-
<p className="text-xs text-muted-foreground">
|
|
45
|
-
Your device or browser cannot initialize WebGL.
|
|
46
|
-
</p>
|
|
47
|
-
<button
|
|
48
|
-
className="rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
|
49
|
-
onClick={this.handleRetry}
|
|
50
|
-
type="button"
|
|
51
|
-
>
|
|
52
|
-
Retry
|
|
53
|
-
</button>
|
|
54
|
-
</div>
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<div className="h-full w-full" key={this.state.retryKey}>
|
|
60
|
-
{this.props.children}
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
}
|