talking-head-studio 0.2.0
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/LICENSE +21 -0
- package/README.md +459 -0
- package/dist/TalkingHead.d.ts +35 -0
- package/dist/TalkingHead.d.ts.map +1 -0
- package/dist/TalkingHead.js +107 -0
- package/dist/TalkingHead.web.d.ts +35 -0
- package/dist/TalkingHead.web.d.ts.map +1 -0
- package/dist/TalkingHead.web.js +117 -0
- package/dist/__tests__/TalkingHead.test.d.ts +2 -0
- package/dist/__tests__/TalkingHead.test.d.ts.map +1 -0
- package/dist/__tests__/TalkingHead.test.js +23 -0
- package/dist/__tests__/sketchfab.test.d.ts +2 -0
- package/dist/__tests__/sketchfab.test.d.ts.map +1 -0
- package/dist/__tests__/sketchfab.test.js +21 -0
- package/dist/appearance/apply.d.ts +7 -0
- package/dist/appearance/apply.d.ts.map +1 -0
- package/dist/appearance/apply.js +56 -0
- package/dist/appearance/index.d.ts +5 -0
- package/dist/appearance/index.d.ts.map +1 -0
- package/dist/appearance/index.js +3 -0
- package/dist/appearance/matchers.d.ts +3 -0
- package/dist/appearance/matchers.d.ts.map +1 -0
- package/dist/appearance/matchers.js +32 -0
- package/dist/appearance/schema.d.ts +9 -0
- package/dist/appearance/schema.d.ts.map +1 -0
- package/dist/appearance/schema.js +20 -0
- package/dist/editor/AvatarCanvas.d.ts +16 -0
- package/dist/editor/AvatarCanvas.d.ts.map +1 -0
- package/dist/editor/AvatarCanvas.js +85 -0
- package/dist/editor/AvatarCanvasErrorBoundary.d.ts +17 -0
- package/dist/editor/AvatarCanvasErrorBoundary.d.ts.map +1 -0
- package/dist/editor/AvatarCanvasErrorBoundary.js +41 -0
- package/dist/editor/AvatarModel.d.ts +12 -0
- package/dist/editor/AvatarModel.d.ts.map +1 -0
- package/dist/editor/AvatarModel.js +31 -0
- package/dist/editor/RigidAccessory.d.ts +15 -0
- package/dist/editor/RigidAccessory.d.ts.map +1 -0
- package/dist/editor/RigidAccessory.js +76 -0
- package/dist/editor/SkinnedClothing.d.ts +7 -0
- package/dist/editor/SkinnedClothing.d.ts.map +1 -0
- package/dist/editor/SkinnedClothing.js +88 -0
- package/dist/editor/index.d.ts +6 -0
- package/dist/editor/index.d.ts.map +1 -0
- package/dist/editor/index.js +4 -0
- package/dist/editor/types.d.ts +28 -0
- package/dist/editor/types.d.ts.map +1 -0
- package/dist/editor/types.js +1 -0
- package/dist/html.d.ts +13 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +560 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.web.d.ts +4 -0
- package/dist/index.web.d.ts.map +1 -0
- package/dist/index.web.js +2 -0
- package/dist/sketchfab/api.d.ts +12 -0
- package/dist/sketchfab/api.d.ts.map +1 -0
- package/dist/sketchfab/api.js +52 -0
- package/dist/sketchfab/categories.d.ts +5 -0
- package/dist/sketchfab/categories.d.ts.map +1 -0
- package/dist/sketchfab/categories.js +124 -0
- package/dist/sketchfab/index.d.ts +7 -0
- package/dist/sketchfab/index.d.ts.map +1 -0
- package/dist/sketchfab/index.js +3 -0
- package/dist/sketchfab/types.d.ts +51 -0
- package/dist/sketchfab/types.d.ts.map +1 -0
- package/dist/sketchfab/types.js +1 -0
- package/dist/sketchfab/useSketchfabSearch.d.ts +19 -0
- package/dist/sketchfab/useSketchfabSearch.d.ts.map +1 -0
- package/dist/sketchfab/useSketchfabSearch.js +78 -0
- package/dist/voice/convertToWav.d.ts +6 -0
- package/dist/voice/convertToWav.d.ts.map +1 -0
- package/dist/voice/convertToWav.js +74 -0
- package/dist/voice/index.d.ts +6 -0
- package/dist/voice/index.d.ts.map +1 -0
- package/dist/voice/index.js +3 -0
- package/dist/voice/useAudioPlayer.d.ts +11 -0
- package/dist/voice/useAudioPlayer.d.ts.map +1 -0
- package/dist/voice/useAudioPlayer.js +61 -0
- package/dist/voice/useAudioRecording.d.ts +14 -0
- package/dist/voice/useAudioRecording.d.ts.map +1 -0
- package/dist/voice/useAudioRecording.js +162 -0
- package/package.json +120 -0
- package/src/TalkingHead.tsx +207 -0
- package/src/TalkingHead.web.tsx +210 -0
- package/src/__tests__/TalkingHead.test.tsx +32 -0
- package/src/__tests__/sketchfab.test.ts +24 -0
- package/src/appearance/apply.ts +94 -0
- package/src/appearance/index.ts +4 -0
- package/src/appearance/matchers.ts +43 -0
- package/src/appearance/schema.ts +35 -0
- package/src/editor/AvatarCanvas.tsx +167 -0
- package/src/editor/AvatarCanvasErrorBoundary.tsx +64 -0
- package/src/editor/AvatarModel.tsx +49 -0
- package/src/editor/RigidAccessory.tsx +130 -0
- package/src/editor/SkinnedClothing.tsx +114 -0
- package/src/editor/index.ts +5 -0
- package/src/editor/r3f-shim.d.ts +34 -0
- package/src/editor/types.ts +30 -0
- package/src/html.ts +572 -0
- package/src/index.ts +8 -0
- package/src/index.web.ts +8 -0
- package/src/sketchfab/api.ts +82 -0
- package/src/sketchfab/categories.ts +127 -0
- package/src/sketchfab/index.ts +6 -0
- package/src/sketchfab/types.ts +40 -0
- package/src/sketchfab/useSketchfabSearch.ts +110 -0
- package/src/voice/convertToWav.ts +87 -0
- package/src/voice/index.ts +7 -0
- package/src/voice/useAudioPlayer.ts +78 -0
- package/src/voice/useAudioRecording.ts +207 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, } from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { buildAvatarHtml } from './html';
|
|
4
|
+
export const TalkingHead = forwardRef(({ avatarUrl, authToken, mood = 'neutral', cameraView = 'upper', cameraDistance = -0.5, hairColor, skinColor, eyeColor, accessories, onReady, onError, style, }, ref) => {
|
|
5
|
+
const iframeRef = useRef(null);
|
|
6
|
+
const readyRef = useRef(false);
|
|
7
|
+
const accessoriesRef = useRef(accessories);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
accessoriesRef.current = accessories;
|
|
10
|
+
}, [accessories]);
|
|
11
|
+
const post = useCallback((msg) => {
|
|
12
|
+
iframeRef.current?.contentWindow?.postMessage(JSON.stringify(msg), '*');
|
|
13
|
+
}, []);
|
|
14
|
+
useImperativeHandle(ref, () => ({
|
|
15
|
+
sendAmplitude: (amplitude) => post({ type: 'amplitude', value: amplitude }),
|
|
16
|
+
setMood: (nextMood) => post({ type: 'mood', value: nextMood }),
|
|
17
|
+
setHairColor: (color) => post({ type: 'hair_color', value: color }),
|
|
18
|
+
setSkinColor: (color) => post({ type: 'skin_color', value: color }),
|
|
19
|
+
setEyeColor: (color) => post({ type: 'eye_color', value: color }),
|
|
20
|
+
setAccessories: (newAccessories) => post({ type: 'set_accessories', accessories: newAccessories }),
|
|
21
|
+
}), [post]);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (readyRef.current)
|
|
24
|
+
post({ type: 'mood', value: mood });
|
|
25
|
+
}, [mood, post]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (accessories && readyRef.current) {
|
|
28
|
+
post({ type: 'set_accessories', accessories });
|
|
29
|
+
}
|
|
30
|
+
}, [accessories, post]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (hairColor && readyRef.current)
|
|
33
|
+
post({ type: 'hair_color', value: hairColor });
|
|
34
|
+
}, [hairColor, post]);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (skinColor && readyRef.current)
|
|
37
|
+
post({ type: 'skin_color', value: skinColor });
|
|
38
|
+
}, [skinColor, post]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (eyeColor && readyRef.current)
|
|
41
|
+
post({ type: 'eye_color', value: eyeColor });
|
|
42
|
+
}, [eyeColor, post]);
|
|
43
|
+
const [initialMood] = React.useState(mood);
|
|
44
|
+
const [initialHairColor] = React.useState(hairColor);
|
|
45
|
+
const [initialSkinColor] = React.useState(skinColor);
|
|
46
|
+
const [initialEyeColor] = React.useState(eyeColor);
|
|
47
|
+
const html = useMemo(() => buildAvatarHtml({
|
|
48
|
+
avatarUrl,
|
|
49
|
+
authToken,
|
|
50
|
+
mood: initialMood,
|
|
51
|
+
cameraView,
|
|
52
|
+
cameraDistance,
|
|
53
|
+
initialHairColor,
|
|
54
|
+
initialSkinColor,
|
|
55
|
+
initialEyeColor,
|
|
56
|
+
}), [
|
|
57
|
+
avatarUrl,
|
|
58
|
+
authToken,
|
|
59
|
+
cameraView,
|
|
60
|
+
cameraDistance,
|
|
61
|
+
initialMood,
|
|
62
|
+
initialHairColor,
|
|
63
|
+
initialSkinColor,
|
|
64
|
+
initialEyeColor,
|
|
65
|
+
]);
|
|
66
|
+
// The HTML references window.ReactNativeWebView.postMessage — we need to
|
|
67
|
+
// inject that shim so messages come back to us via window.addEventListener.
|
|
68
|
+
const srcdoc = useMemo(() => {
|
|
69
|
+
const shim = `<script>window.ReactNativeWebView = { postMessage: function(d) { window.parent.postMessage(d, '*'); } };</script>`;
|
|
70
|
+
// Insert the shim right after <head> so it's available before the module script runs
|
|
71
|
+
return html.replace('<head>', '<head>' + shim);
|
|
72
|
+
}, [html]);
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const onMessage = (event) => {
|
|
75
|
+
// Only accept messages from our iframe
|
|
76
|
+
if (iframeRef.current && event.source !== iframeRef.current.contentWindow)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
const msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
80
|
+
if (msg.type === 'ready') {
|
|
81
|
+
readyRef.current = true;
|
|
82
|
+
if (accessoriesRef.current?.length) {
|
|
83
|
+
post({ type: 'set_accessories', accessories: accessoriesRef.current });
|
|
84
|
+
}
|
|
85
|
+
onReady?.();
|
|
86
|
+
}
|
|
87
|
+
else if (msg.type === 'error') {
|
|
88
|
+
onError?.(msg.message);
|
|
89
|
+
}
|
|
90
|
+
else if (msg.type === 'log') {
|
|
91
|
+
console.log('[TalkingHead]', msg.message);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// ignore non-JSON messages
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
window.addEventListener('message', onMessage);
|
|
99
|
+
return () => window.removeEventListener('message', onMessage);
|
|
100
|
+
}, [onReady, onError, post]);
|
|
101
|
+
return (<View style={[styles.container, style]}>
|
|
102
|
+
<iframe ref={iframeRef} srcDoc={srcdoc} style={{
|
|
103
|
+
width: '100%',
|
|
104
|
+
height: '100%',
|
|
105
|
+
border: 'none',
|
|
106
|
+
backgroundColor: 'transparent',
|
|
107
|
+
}} sandbox="allow-scripts allow-same-origin" title="TalkingHead Avatar"/>
|
|
108
|
+
</View>);
|
|
109
|
+
});
|
|
110
|
+
TalkingHead.displayName = 'TalkingHead';
|
|
111
|
+
const styles = StyleSheet.create({
|
|
112
|
+
container: {
|
|
113
|
+
overflow: 'hidden',
|
|
114
|
+
borderRadius: 12,
|
|
115
|
+
backgroundColor: 'transparent',
|
|
116
|
+
},
|
|
117
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TalkingHead.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/TalkingHead.test.tsx"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react-native';
|
|
3
|
+
import { TalkingHead } from '../TalkingHead';
|
|
4
|
+
// Mock react-native-webview
|
|
5
|
+
jest.mock('react-native-webview', () => {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
7
|
+
const React = require('react');
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
9
|
+
const { View } = require('react-native');
|
|
10
|
+
const MockWebView = React.forwardRef((props, ref) => {
|
|
11
|
+
React.useImperativeHandle(ref, () => ({
|
|
12
|
+
postMessage: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
return <View {...props}/>;
|
|
15
|
+
});
|
|
16
|
+
MockWebView.displayName = 'MockWebView';
|
|
17
|
+
return { WebView: MockWebView };
|
|
18
|
+
});
|
|
19
|
+
describe('TalkingHead', () => {
|
|
20
|
+
it('renders without crashing', () => {
|
|
21
|
+
render(<TalkingHead avatarUrl="https://example.com/avatar.glb" style={{ flex: 1 }}/>);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sketchfab.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/sketchfab.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ACCESSORY_CATEGORIES } from '../sketchfab';
|
|
2
|
+
describe('ACCESSORY_CATEGORIES', () => {
|
|
3
|
+
it('includes the full accessory category set used by the app browsers', () => {
|
|
4
|
+
expect(ACCESSORY_CATEGORIES.map((category) => category.id)).toEqual(expect.arrayContaining([
|
|
5
|
+
'hair',
|
|
6
|
+
'hat',
|
|
7
|
+
'glasses',
|
|
8
|
+
'necklace',
|
|
9
|
+
'handheld',
|
|
10
|
+
'glove',
|
|
11
|
+
'wings',
|
|
12
|
+
'tail',
|
|
13
|
+
'cape',
|
|
14
|
+
'belt',
|
|
15
|
+
'shoulder_pad',
|
|
16
|
+
'top',
|
|
17
|
+
'bottom',
|
|
18
|
+
'footwear',
|
|
19
|
+
]));
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type AvatarAppearance } from './schema';
|
|
2
|
+
type Object3DLike = {
|
|
3
|
+
traverse?: (visitor: (object: unknown) => void) => void;
|
|
4
|
+
};
|
|
5
|
+
export declare function applyAppearanceToObject3D(object3d: Object3DLike, appearance: AvatarAppearance): void;
|
|
6
|
+
export {};
|
|
7
|
+
//# sourceMappingURL=apply.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/appearance/apply.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,gBAAgB,EAAuB,MAAM,UAAU,CAAC;AAmBtE,KAAK,YAAY,GAAG;IAClB,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,KAAK,IAAI,CAAC;CACzD,CAAC;AAcF,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,gBAAgB,GAC3B,IAAI,CAsDN"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { pickTargetForMaterialName } from './matchers';
|
|
2
|
+
import { normalizeAppearance } from './schema';
|
|
3
|
+
function asMaterialArray(material) {
|
|
4
|
+
if (!material) {
|
|
5
|
+
return [];
|
|
6
|
+
}
|
|
7
|
+
return Array.isArray(material) ? material : [material];
|
|
8
|
+
}
|
|
9
|
+
function getVertexCount(mesh) {
|
|
10
|
+
return mesh.geometry?.attributes?.position?.count ?? 0;
|
|
11
|
+
}
|
|
12
|
+
export function applyAppearanceToObject3D(object3d, appearance) {
|
|
13
|
+
if (typeof object3d.traverse !== 'function') {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const normalizedAppearance = normalizeAppearance(appearance);
|
|
17
|
+
// First pass: apply name-matched colors and track whether skin was matched,
|
|
18
|
+
// also track the largest mesh for fallback skin coloring.
|
|
19
|
+
let skinMatched = false;
|
|
20
|
+
let largestVertexCount = 0;
|
|
21
|
+
let largestMeshMaterial = null;
|
|
22
|
+
object3d.traverse((object) => {
|
|
23
|
+
const mesh = object;
|
|
24
|
+
if (!mesh?.isMesh) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const vertexCount = getVertexCount(mesh);
|
|
28
|
+
for (const material of asMaterialArray(mesh.material)) {
|
|
29
|
+
if (!material || typeof material.name !== 'string') {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const target = pickTargetForMaterialName(material.name);
|
|
33
|
+
if (!target) {
|
|
34
|
+
// Track the largest unmatched mesh as a skin fallback candidate
|
|
35
|
+
if (vertexCount > largestVertexCount && material.color && typeof material.color.set === 'function') {
|
|
36
|
+
largestVertexCount = vertexCount;
|
|
37
|
+
largestMeshMaterial = material;
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (target === 'skinColor') {
|
|
42
|
+
skinMatched = true;
|
|
43
|
+
}
|
|
44
|
+
const color = normalizedAppearance[target];
|
|
45
|
+
if (!color || !material.color || typeof material.color.set !== 'function') {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
material.color.set(color);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// Second pass: if no skin material was found by name, apply skin color to
|
|
52
|
+
// the largest mesh — skin is the largest organ after all.
|
|
53
|
+
if (!skinMatched && largestMeshMaterial && normalizedAppearance.skinColor) {
|
|
54
|
+
largestMeshMaterial.color.set(normalizedAppearance.skinColor);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { applyAppearanceToObject3D } from './apply';
|
|
2
|
+
export { pickTargetForMaterialName } from './matchers';
|
|
3
|
+
export { type AvatarAppearance, normalizeAppearance } from './schema';
|
|
4
|
+
export type { AppearanceTarget } from './matchers';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/appearance/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,yBAAyB,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,EAAE,KAAK,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AACtE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matchers.d.ts","sourceRoot":"","sources":["../../src/appearance/matchers.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG,WAAW,GAAG,WAAW,GAAG,UAAU,CAAC;AAwBtE,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI,CAkB/E"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function tokenizeMaterialName(name) {
|
|
2
|
+
return name
|
|
3
|
+
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.split(/[^a-z\d]+/)
|
|
6
|
+
.filter(Boolean);
|
|
7
|
+
}
|
|
8
|
+
// Exact token match: any token equals the candidate exactly
|
|
9
|
+
function hasToken(tokens, candidates) {
|
|
10
|
+
return candidates.some((candidate) => tokens.includes(candidate));
|
|
11
|
+
}
|
|
12
|
+
// Prefix match: candidate appears at the START of a token.
|
|
13
|
+
// e.g. "eye" matches token "eyeball", "eyelid", "eyebrow" but NOT "chair" (c-hair).
|
|
14
|
+
// We intentionally avoid suffix/contains to prevent false positives like chair→hair.
|
|
15
|
+
function hasTokenPrefix(tokens, candidates) {
|
|
16
|
+
return candidates.some((candidate) => tokens.some((token) => token.startsWith(candidate) && token.length > candidate.length));
|
|
17
|
+
}
|
|
18
|
+
export function pickTargetForMaterialName(name) {
|
|
19
|
+
const tokens = tokenizeMaterialName(name);
|
|
20
|
+
if (hasToken(tokens, ['hair', 'fur']) || hasTokenPrefix(tokens, ['hair', 'fur'])) {
|
|
21
|
+
return 'hairColor';
|
|
22
|
+
}
|
|
23
|
+
if (hasToken(tokens, ['skin', 'body', 'face']) || hasTokenPrefix(tokens, ['skin', 'body', 'face'])) {
|
|
24
|
+
return 'skinColor';
|
|
25
|
+
}
|
|
26
|
+
// "eye" prefix catches: eyeball, eyelid, eyebrow, eyelash
|
|
27
|
+
// "eye" suffix catches: lefteye, righteye
|
|
28
|
+
if (hasToken(tokens, ['eye', 'iris']) || hasTokenPrefix(tokens, ['eye', 'iris'])) {
|
|
29
|
+
return 'eyeColor';
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type AppearanceVersion = 1;
|
|
2
|
+
export interface AvatarAppearance {
|
|
3
|
+
version: AppearanceVersion;
|
|
4
|
+
hairColor?: string;
|
|
5
|
+
skinColor?: string;
|
|
6
|
+
eyeColor?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function normalizeAppearance(input: AvatarAppearance): AvatarAppearance;
|
|
9
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/appearance/schema.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC;AAElC,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,iBAAiB,CAAC;IAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAgBD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,gBAAgB,GAAG,gBAAgB,CAW7E"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const HEX_COLOR_PATTERN = /^#(?:[\dA-Fa-f]{3}|[\dA-Fa-f]{6})$/;
|
|
2
|
+
function normalizeHexColor(value) {
|
|
3
|
+
if (!HEX_COLOR_PATTERN.test(value)) {
|
|
4
|
+
throw new Error(`Invalid color format: ${value}`);
|
|
5
|
+
}
|
|
6
|
+
const raw = value.slice(1);
|
|
7
|
+
const expanded = raw.length === 3 ? `${raw[0]}${raw[0]}${raw[1]}${raw[1]}${raw[2]}${raw[2]}` : raw;
|
|
8
|
+
return `#${expanded.toUpperCase()}`;
|
|
9
|
+
}
|
|
10
|
+
export function normalizeAppearance(input) {
|
|
11
|
+
if (input.version !== 1) {
|
|
12
|
+
throw new Error(`Unsupported appearance version: ${input.version}`);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
version: input.version,
|
|
16
|
+
hairColor: input.hairColor != null ? normalizeHexColor(input.hairColor) : undefined,
|
|
17
|
+
skinColor: input.skinColor != null ? normalizeHexColor(input.skinColor) : undefined,
|
|
18
|
+
eyeColor: input.eyeColor != null ? normalizeHexColor(input.eyeColor) : undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AvatarAppearance } from '../appearance';
|
|
2
|
+
import type { AssetPlacement, EquippedAsset } from './types';
|
|
3
|
+
interface AvatarCanvasProps {
|
|
4
|
+
avatarUrl: string;
|
|
5
|
+
appearance?: AvatarAppearance;
|
|
6
|
+
equipped?: EquippedAsset[];
|
|
7
|
+
placements?: Record<string, AssetPlacement>;
|
|
8
|
+
editingAssetId?: string | null;
|
|
9
|
+
onPlacementChange?: (assetId: string, placement: AssetPlacement) => void;
|
|
10
|
+
onSceneRef?: (scene: any) => void;
|
|
11
|
+
style?: React.CSSProperties;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function AvatarCanvas({ avatarUrl, appearance, equipped, placements, editingAssetId, onPlacementChange, onSceneRef, style, className, }: AvatarCanvasProps): import("react").JSX.Element;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=AvatarCanvas.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AvatarCanvas.d.ts","sourceRoot":"","sources":["../../src/editor/AvatarCanvas.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAKtD,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAW7D,UAAU,iBAAiB;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,QAAQ,CAAC,EAAE,aAAa,EAAE,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC5C,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,cAAc,KAAK,IAAI,CAAC;IAEzE,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;IAClC,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,YAAY,CAAC,EAC3B,SAAS,EACT,UAAU,EACV,QAAa,EACb,UAAe,EACf,cAAqB,EACrB,iBAAiB,EACjB,UAAU,EACV,KAAK,EACL,SAAS,GACV,EAAE,iBAAiB,+BA0HnB"}
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { AvatarCanvasErrorBoundary } from './AvatarCanvasErrorBoundary';
|
|
6
|
+
import { AvatarModel } from './AvatarModel';
|
|
7
|
+
import { RigidAccessory } from './RigidAccessory';
|
|
8
|
+
import { SkinnedClothing } from './SkinnedClothing';
|
|
9
|
+
/** Captures the R3F scene object (which contains everything) and passes it up. */
|
|
10
|
+
function SceneRefCapture({ onSceneRef }) {
|
|
11
|
+
const { scene } = useThree();
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
onSceneRef(scene);
|
|
14
|
+
}, [scene, onSceneRef]);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
export function AvatarCanvas({ avatarUrl, appearance, equipped = [], placements = {}, editingAssetId = null, onPlacementChange, onSceneRef, style, className, }) {
|
|
18
|
+
const [skeleton, setSkeleton] = useState(null);
|
|
19
|
+
const [avatarScene, setAvatarScene] = useState(null);
|
|
20
|
+
const [cameraTarget, setCameraTarget] = useState([0, 1, 0]);
|
|
21
|
+
const [cameraPosition, setCameraPosition] = useState([0, 1.2, 2.5]);
|
|
22
|
+
const canvasRef = useRef(null);
|
|
23
|
+
const handleSkeletonReady = useCallback((skel) => {
|
|
24
|
+
setSkeleton(skel);
|
|
25
|
+
if (skel.bones.length > 0) {
|
|
26
|
+
const root = skel.bones[0].parent ?? skel.bones[0];
|
|
27
|
+
setAvatarScene(root);
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
const handleBoundsReady = useCallback((center, height) => {
|
|
31
|
+
const dist = Math.max(height * 1.4, 1.5);
|
|
32
|
+
setCameraTarget([center.x, center.y, center.z]);
|
|
33
|
+
setCameraPosition([center.x, center.y + height * 0.1, center.z + dist]);
|
|
34
|
+
}, []);
|
|
35
|
+
const equippedItems = equipped.filter(Boolean);
|
|
36
|
+
const wrapperClass = [
|
|
37
|
+
'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%]',
|
|
38
|
+
className,
|
|
39
|
+
]
|
|
40
|
+
.filter(Boolean)
|
|
41
|
+
.join(' ');
|
|
42
|
+
return (<div className={wrapperClass} style={style}>
|
|
43
|
+
{/* Subtle grid floor */}
|
|
44
|
+
<div className="absolute inset-0 opacity-[0.04] pointer-events-none" 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' }}/>
|
|
45
|
+
{avatarUrl ? (<AvatarCanvasErrorBoundary>
|
|
46
|
+
<Canvas ref={canvasRef} camera={{ position: cameraPosition, fov: 45 }} style={{ width: '100%', height: '100%' }}>
|
|
47
|
+
<Suspense fallback={null}>
|
|
48
|
+
{onSceneRef && <SceneRefCapture onSceneRef={onSceneRef}/>}
|
|
49
|
+
<ambientLight intensity={0.6}/>
|
|
50
|
+
<directionalLight position={[5, 5, 5]} intensity={0.8}/>
|
|
51
|
+
<Environment preset="studio"/>
|
|
52
|
+
<OrbitControls target={cameraTarget} minDistance={1} maxDistance={10} enablePan={false} makeDefault/>
|
|
53
|
+
|
|
54
|
+
<AvatarModel url={avatarUrl} appearance={appearance} scale={1} onSkeletonReady={handleSkeletonReady} onBoundsReady={handleBoundsReady}/>
|
|
55
|
+
|
|
56
|
+
{equippedItems.map((asset) => {
|
|
57
|
+
if (!asset)
|
|
58
|
+
return null;
|
|
59
|
+
const llmPlacement = placements[asset.id];
|
|
60
|
+
const isEditing = editingAssetId === asset.id;
|
|
61
|
+
// If a placement is provided, always render as rigid attachment
|
|
62
|
+
if (llmPlacement) {
|
|
63
|
+
const bone = llmPlacement.bone ?? asset.attach_bone;
|
|
64
|
+
if (!bone)
|
|
65
|
+
return null;
|
|
66
|
+
return (<RigidAccessory key={asset.id} assetId={asset.id} url={asset.url} avatarScene={avatarScene} attachBone={bone} offsetPosition={llmPlacement.position} offsetRotation={llmPlacement.rotation} scale={llmPlacement.scale} isEditing={isEditing} onPlacementChange={onPlacementChange}/>);
|
|
67
|
+
}
|
|
68
|
+
if (asset.type === 'skinned') {
|
|
69
|
+
return (<SkinnedClothing key={asset.id} url={asset.url} avatarSkeleton={skeleton}/>);
|
|
70
|
+
}
|
|
71
|
+
if (asset.type === 'rigid') {
|
|
72
|
+
const bone = asset.attach_bone;
|
|
73
|
+
if (!bone)
|
|
74
|
+
return null;
|
|
75
|
+
return (<RigidAccessory key={asset.id} assetId={asset.id} url={asset.url} avatarScene={avatarScene} attachBone={bone} offsetPosition={asset.offset_position} offsetRotation={asset.offset_rotation} isEditing={isEditing} onPlacementChange={onPlacementChange}/>);
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
})}
|
|
79
|
+
</Suspense>
|
|
80
|
+
</Canvas>
|
|
81
|
+
</AvatarCanvasErrorBoundary>) : (<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
82
|
+
<p>Select a base avatar to get started</p>
|
|
83
|
+
</div>)}
|
|
84
|
+
</div>);
|
|
85
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
interface AvatarCanvasErrorBoundaryProps {
|
|
3
|
+
children: ReactNode;
|
|
4
|
+
}
|
|
5
|
+
interface AvatarCanvasErrorBoundaryState {
|
|
6
|
+
hasError: boolean;
|
|
7
|
+
retryKey: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class AvatarCanvasErrorBoundary extends Component<AvatarCanvasErrorBoundaryProps, AvatarCanvasErrorBoundaryState> {
|
|
10
|
+
state: AvatarCanvasErrorBoundaryState;
|
|
11
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
|
|
12
|
+
static getDerivedStateFromError(): AvatarCanvasErrorBoundaryState;
|
|
13
|
+
private handleRetry;
|
|
14
|
+
render(): import("react").JSX.Element;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=AvatarCanvasErrorBoundary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AvatarCanvasErrorBoundary.d.ts","sourceRoot":"","sources":["../../src/editor/AvatarCanvasErrorBoundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAElE,UAAU,8BAA8B;IACtC,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,UAAU,8BAA8B;IACtC,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,qBAAa,yBAA0B,SAAQ,SAAS,CACtD,8BAA8B,EAC9B,8BAA8B,CAC/B;IACC,KAAK,EAAE,8BAA8B,CAGnC;IAEF,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS,GAAG,IAAI;IAI3D,MAAM,CAAC,wBAAwB,IAAI,8BAA8B;IAOjE,OAAO,CAAC,WAAW,CAKjB;IAEF,MAAM;CAyBP"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Component } from 'react';
|
|
2
|
+
export class AvatarCanvasErrorBoundary extends Component {
|
|
3
|
+
constructor() {
|
|
4
|
+
super(...arguments);
|
|
5
|
+
this.state = {
|
|
6
|
+
hasError: false,
|
|
7
|
+
retryKey: 0,
|
|
8
|
+
};
|
|
9
|
+
this.handleRetry = () => {
|
|
10
|
+
this.setState((prev) => ({
|
|
11
|
+
hasError: false,
|
|
12
|
+
retryKey: prev.retryKey + 1,
|
|
13
|
+
}));
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
componentDidCatch(error, errorInfo) {
|
|
17
|
+
console.error('[AvatarCanvas] WebGL error', error, errorInfo.componentStack);
|
|
18
|
+
}
|
|
19
|
+
static getDerivedStateFromError() {
|
|
20
|
+
return {
|
|
21
|
+
hasError: true,
|
|
22
|
+
retryKey: 0,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
render() {
|
|
26
|
+
if (this.state.hasError) {
|
|
27
|
+
return (<div className="flex h-full w-full flex-col items-center justify-center gap-3 p-4 text-center">
|
|
28
|
+
<p className="text-sm font-medium text-foreground">3D rendering unavailable</p>
|
|
29
|
+
<p className="text-xs text-muted-foreground">
|
|
30
|
+
Your device or browser cannot initialize WebGL.
|
|
31
|
+
</p>
|
|
32
|
+
<button className="rounded-md border px-3 py-1.5 text-xs font-medium hover:bg-muted" onClick={this.handleRetry} type="button">
|
|
33
|
+
Retry
|
|
34
|
+
</button>
|
|
35
|
+
</div>);
|
|
36
|
+
}
|
|
37
|
+
return (<div className="h-full w-full" key={this.state.retryKey}>
|
|
38
|
+
{this.props.children}
|
|
39
|
+
</div>);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Vector3 } from 'three';
|
|
2
|
+
import type { AvatarAppearance } from '../appearance';
|
|
3
|
+
interface AvatarModelProps {
|
|
4
|
+
url: string;
|
|
5
|
+
appearance?: AvatarAppearance;
|
|
6
|
+
scale?: number;
|
|
7
|
+
onSkeletonReady?: (skeleton: any) => void;
|
|
8
|
+
onBoundsReady?: (center: typeof Vector3.prototype, height: number) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function AvatarModel({ url, appearance, scale, onSkeletonReady, onBoundsReady, }: AvatarModelProps): import("react").JSX.Element;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=AvatarModel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AvatarModel.d.ts","sourceRoot":"","sources":["../../src/editor/AvatarModel.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAQ,OAAO,EAAE,MAAM,OAAO,CAAC;AAEtC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEtD,UAAU,gBAAgB;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,eAAe,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,KAAK,IAAI,CAAC;IAC1C,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5E;AAED,wBAAgB,WAAW,CAAC,EAC1B,GAAG,EACH,UAAU,EACV,KAAS,EACT,eAAe,EACf,aAAa,GACd,EAAE,gBAAgB,+BA0BlB"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { useGLTF } from '@react-three/drei';
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
|
+
import { Box3, Vector3 } from 'three';
|
|
5
|
+
import { applyAppearanceToObject3D } from '../appearance';
|
|
6
|
+
export function AvatarModel({ url, appearance, scale = 1, onSkeletonReady, onBoundsReady, }) {
|
|
7
|
+
const { scene } = useGLTF(url);
|
|
8
|
+
const groupRef = useRef(null);
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!scene)
|
|
11
|
+
return;
|
|
12
|
+
scene.traverse((child) => {
|
|
13
|
+
if (child.isSkinnedMesh && child.skeleton) {
|
|
14
|
+
onSkeletonReady?.(child.skeleton);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
if (onBoundsReady) {
|
|
18
|
+
const box = new Box3().setFromObject(scene);
|
|
19
|
+
const center = new Vector3();
|
|
20
|
+
box.getCenter(center);
|
|
21
|
+
const height = box.max.y - box.min.y;
|
|
22
|
+
onBoundsReady(center, height);
|
|
23
|
+
}
|
|
24
|
+
}, [scene, onSkeletonReady, onBoundsReady]);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!scene)
|
|
27
|
+
return;
|
|
28
|
+
applyAppearanceToObject3D(scene, appearance ?? { version: 1 });
|
|
29
|
+
}, [scene, appearance]);
|
|
30
|
+
return <primitive ref={groupRef} object={scene} scale={scale}/>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AssetPlacement } from './types';
|
|
2
|
+
interface RigidAccessoryProps {
|
|
3
|
+
assetId: string;
|
|
4
|
+
url: string;
|
|
5
|
+
avatarScene: any;
|
|
6
|
+
attachBone: string;
|
|
7
|
+
offsetPosition?: [number, number, number];
|
|
8
|
+
offsetRotation?: [number, number, number];
|
|
9
|
+
scale?: number;
|
|
10
|
+
isEditing?: boolean;
|
|
11
|
+
onPlacementChange?: (assetId: string, placement: AssetPlacement) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function RigidAccessory({ assetId, url, avatarScene, attachBone, offsetPosition, offsetRotation, scale, isEditing, onPlacementChange, }: RigidAccessoryProps): import("react").JSX.Element | null;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=RigidAccessory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RigidAccessory.d.ts","sourceRoot":"","sources":["../../src/editor/RigidAccessory.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,UAAU,mBAAmB;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IAEZ,WAAW,EAAE,GAAG,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,cAAc,KAAK,IAAI,CAAC;CAC1E;AAED,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,GAAG,EACH,WAAW,EACX,UAAU,EACV,cAAc,EACd,cAAc,EACd,KAAS,EACT,SAAiB,EACjB,iBAAiB,GAClB,EAAE,mBAAmB,sCAoGrB"}
|