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