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.
Files changed (111) hide show
  1. package/README.md +8 -7
  2. package/dist/TalkingHead.d.ts +0 -1
  3. package/dist/TalkingHead.js +31 -29
  4. package/dist/TalkingHead.web.d.ts +1 -3
  5. package/dist/TalkingHead.web.js +72 -43
  6. package/dist/appearance/apply.d.ts +0 -1
  7. package/dist/appearance/apply.js +8 -5
  8. package/dist/appearance/index.d.ts +0 -1
  9. package/dist/appearance/index.js +9 -3
  10. package/dist/appearance/matchers.d.ts +0 -1
  11. package/dist/appearance/matchers.js +4 -1
  12. package/dist/appearance/schema.d.ts +0 -1
  13. package/dist/appearance/schema.js +4 -1
  14. package/dist/editor/AvatarCanvas.d.ts +1 -2
  15. package/dist/editor/AvatarCanvas.js +44 -60
  16. package/dist/editor/AvatarCanvasErrorBoundary.d.ts +1 -2
  17. package/dist/editor/AvatarCanvasErrorBoundary.js +9 -14
  18. package/dist/editor/AvatarModel.d.ts +1 -2
  19. package/dist/editor/AvatarModel.js +17 -13
  20. package/dist/editor/RigidAccessory.d.ts +1 -2
  21. package/dist/editor/RigidAccessory.js +19 -17
  22. package/dist/editor/SkinnedClothing.d.ts +1 -2
  23. package/dist/editor/SkinnedClothing.js +46 -9
  24. package/dist/editor/index.d.ts +0 -1
  25. package/dist/editor/index.js +11 -4
  26. package/dist/editor/types.d.ts +0 -1
  27. package/dist/editor/types.js +2 -1
  28. package/dist/html.d.ts +0 -1
  29. package/dist/html.js +8 -5
  30. package/dist/index.d.ts +0 -1
  31. package/dist/index.js +20 -2
  32. package/dist/index.web.d.ts +0 -1
  33. package/dist/index.web.js +20 -2
  34. package/dist/sketchfab/api.d.ts +0 -1
  35. package/dist/sketchfab/api.js +10 -4
  36. package/dist/sketchfab/categories.d.ts +0 -1
  37. package/dist/sketchfab/categories.js +5 -2
  38. package/dist/sketchfab/index.d.ts +0 -1
  39. package/dist/sketchfab/index.js +13 -3
  40. package/dist/sketchfab/types.d.ts +0 -1
  41. package/dist/sketchfab/types.js +2 -1
  42. package/dist/sketchfab/useSketchfabSearch.d.ts +0 -1
  43. package/dist/sketchfab/useSketchfabSearch.js +20 -17
  44. package/dist/voice/convertToWav.d.ts +0 -1
  45. package/dist/voice/convertToWav.js +4 -1
  46. package/dist/voice/index.d.ts +0 -1
  47. package/dist/voice/index.js +9 -3
  48. package/dist/voice/useAudioPlayer.d.ts +0 -1
  49. package/dist/voice/useAudioPlayer.js +7 -4
  50. package/dist/voice/useAudioRecording.d.ts +0 -1
  51. package/dist/voice/useAudioRecording.js +20 -17
  52. package/package.json +10 -8
  53. package/dist/TalkingHead.d.ts.map +0 -1
  54. package/dist/TalkingHead.web.d.ts.map +0 -1
  55. package/dist/__tests__/TalkingHead.test.d.ts +0 -2
  56. package/dist/__tests__/TalkingHead.test.d.ts.map +0 -1
  57. package/dist/__tests__/TalkingHead.test.js +0 -23
  58. package/dist/__tests__/sketchfab.test.d.ts +0 -2
  59. package/dist/__tests__/sketchfab.test.d.ts.map +0 -1
  60. package/dist/__tests__/sketchfab.test.js +0 -21
  61. package/dist/appearance/apply.d.ts.map +0 -1
  62. package/dist/appearance/index.d.ts.map +0 -1
  63. package/dist/appearance/matchers.d.ts.map +0 -1
  64. package/dist/appearance/schema.d.ts.map +0 -1
  65. package/dist/editor/AvatarCanvas.d.ts.map +0 -1
  66. package/dist/editor/AvatarCanvasErrorBoundary.d.ts.map +0 -1
  67. package/dist/editor/AvatarModel.d.ts.map +0 -1
  68. package/dist/editor/RigidAccessory.d.ts.map +0 -1
  69. package/dist/editor/SkinnedClothing.d.ts.map +0 -1
  70. package/dist/editor/index.d.ts.map +0 -1
  71. package/dist/editor/types.d.ts.map +0 -1
  72. package/dist/html.d.ts.map +0 -1
  73. package/dist/index.d.ts.map +0 -1
  74. package/dist/index.web.d.ts.map +0 -1
  75. package/dist/sketchfab/api.d.ts.map +0 -1
  76. package/dist/sketchfab/categories.d.ts.map +0 -1
  77. package/dist/sketchfab/index.d.ts.map +0 -1
  78. package/dist/sketchfab/types.d.ts.map +0 -1
  79. package/dist/sketchfab/useSketchfabSearch.d.ts.map +0 -1
  80. package/dist/voice/convertToWav.d.ts.map +0 -1
  81. package/dist/voice/index.d.ts.map +0 -1
  82. package/dist/voice/useAudioPlayer.d.ts.map +0 -1
  83. package/dist/voice/useAudioRecording.d.ts.map +0 -1
  84. package/src/TalkingHead.tsx +0 -276
  85. package/src/TalkingHead.web.tsx +0 -220
  86. package/src/__tests__/TalkingHead.test.tsx +0 -32
  87. package/src/__tests__/sketchfab.test.ts +0 -24
  88. package/src/appearance/apply.ts +0 -94
  89. package/src/appearance/index.ts +0 -4
  90. package/src/appearance/matchers.ts +0 -43
  91. package/src/appearance/schema.ts +0 -35
  92. package/src/editor/AvatarCanvas.tsx +0 -167
  93. package/src/editor/AvatarCanvasErrorBoundary.tsx +0 -64
  94. package/src/editor/AvatarModel.tsx +0 -49
  95. package/src/editor/RigidAccessory.tsx +0 -130
  96. package/src/editor/SkinnedClothing.tsx +0 -114
  97. package/src/editor/index.ts +0 -5
  98. package/src/editor/r3f-shim.d.ts +0 -34
  99. package/src/editor/types.ts +0 -30
  100. package/src/html.ts +0 -678
  101. package/src/index.ts +0 -11
  102. package/src/index.web.ts +0 -8
  103. package/src/sketchfab/api.ts +0 -82
  104. package/src/sketchfab/categories.ts +0 -127
  105. package/src/sketchfab/index.ts +0 -6
  106. package/src/sketchfab/types.ts +0 -40
  107. package/src/sketchfab/useSketchfabSearch.ts +0 -110
  108. package/src/voice/convertToWav.ts +0 -87
  109. package/src/voice/index.ts +0 -7
  110. package/src/voice/useAudioPlayer.ts +0 -78
  111. package/src/voice/useAudioRecording.ts +0 -207
@@ -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
- });
@@ -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
- }
@@ -1,4 +0,0 @@
1
- export { applyAppearanceToObject3D } from './apply';
2
- export { pickTargetForMaterialName } from './matchers';
3
- export { type AvatarAppearance, normalizeAppearance } from './schema';
4
- export type { AppearanceTarget } from './matchers';
@@ -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
- }
@@ -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
- }