@linxai/3d-web 0.1.0 → 0.1.1
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/dist/chunk-WUKYLWAZ.mjs +0 -0
- package/dist/hls-UG2Y6VAT.mjs +33000 -0
- package/dist/index.d.mts +87 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +4969 -0
- package/dist/index.mjs +4929 -0
- package/dist/vision_bundle-JCRSWKU6.mjs +4084 -0
- package/package.json +13 -7
- package/src/adapters/AnimationLoaderAdapter.ts +0 -34
- package/src/adapters/ModelLoaderAdapter.ts +0 -34
- package/src/builders/SceneBuilder.tsx +0 -91
- package/src/components/CharacterOverlay.tsx +0 -183
- package/src/components/CharacterView.tsx +0 -165
- package/src/components/ProgressBar.tsx +0 -69
- package/src/components/Scene3D.tsx +0 -89
- package/src/components/Subtitle.tsx +0 -89
- package/src/hooks/useModelLoader.ts +0 -12
- package/src/index.tsx +0 -44
- package/tsconfig.json +0 -23
- package/tsup.config.ts +0 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxai/3d-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "3D 可视化 Web 组件",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
"require": "./dist/index.js"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
19
20
|
},
|
|
20
21
|
"dependencies": {
|
|
21
|
-
"@linxai/3d-shared": "
|
|
22
|
+
"@linxai/3d-shared": "0.1.2"
|
|
22
23
|
},
|
|
23
24
|
"peerDependencies": {
|
|
24
25
|
"@react-three/fiber": ">=8.0.0",
|
|
@@ -30,5 +31,10 @@
|
|
|
30
31
|
"@types/three": "^0.170.0",
|
|
31
32
|
"tsup": "^8.0.0",
|
|
32
33
|
"typescript": "^5.3.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"dev": "tsup src/index.tsx --format cjs,esm --dts --watch",
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
33
39
|
}
|
|
34
|
-
}
|
|
40
|
+
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader.js'
|
|
2
|
-
import type { AnimationLoaderAdapter } from '@linxai/3d-shared/internal'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Web 端动画加载适配器
|
|
6
|
-
* 使用 FBXLoader 从 URL 加载动画
|
|
7
|
-
*/
|
|
8
|
-
export const WebAnimationLoaderAdapter: AnimationLoaderAdapter = {
|
|
9
|
-
load: async (path: string | number, onProgress?: (progress: number) => void) => {
|
|
10
|
-
// Web 平台:path 必须是 string (URL)
|
|
11
|
-
if (typeof path !== 'string') {
|
|
12
|
-
throw new Error('Web 端动画加载: path 必须是字符串 URL')
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
return new Promise((resolve, reject) => {
|
|
16
|
-
const loader = new FBXLoader()
|
|
17
|
-
loader.load(
|
|
18
|
-
path,
|
|
19
|
-
(fbx) => {
|
|
20
|
-
resolve(fbx)
|
|
21
|
-
},
|
|
22
|
-
(event) => {
|
|
23
|
-
if (event.lengthComputable && onProgress) {
|
|
24
|
-
onProgress(event.loaded / event.total)
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
(error) => {
|
|
28
|
-
reject(error)
|
|
29
|
-
}
|
|
30
|
-
)
|
|
31
|
-
})
|
|
32
|
-
},
|
|
33
|
-
}
|
|
34
|
-
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
|
2
|
-
import type { ModelLoaderAdapter } from '@linxai/3d-shared/internal'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Web 端模型加载适配器
|
|
6
|
-
* 使用 GLTFLoader 从 URL 加载模型
|
|
7
|
-
*/
|
|
8
|
-
export const WebModelLoaderAdapter: ModelLoaderAdapter = {
|
|
9
|
-
load: async (path: string | number, onProgress?: (progress: number) => void) => {
|
|
10
|
-
// Web 平台:path 必须是 string (URL)
|
|
11
|
-
if (typeof path !== 'string') {
|
|
12
|
-
throw new Error('Web 端模型加载: path 必须是字符串 URL')
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
return new Promise((resolve, reject) => {
|
|
16
|
-
const loader = new GLTFLoader()
|
|
17
|
-
loader.load(
|
|
18
|
-
path,
|
|
19
|
-
(gltf) => {
|
|
20
|
-
resolve(gltf)
|
|
21
|
-
},
|
|
22
|
-
(event) => {
|
|
23
|
-
if (event.lengthComputable && onProgress) {
|
|
24
|
-
onProgress(event.loaded / event.total)
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
(error) => {
|
|
28
|
-
reject(error)
|
|
29
|
-
}
|
|
30
|
-
)
|
|
31
|
-
})
|
|
32
|
-
},
|
|
33
|
-
}
|
|
34
|
-
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { Canvas } from '@react-three/fiber'
|
|
2
|
-
import { OrbitControls } from '@react-three/drei'
|
|
3
|
-
import type { ReactNode, CSSProperties } from 'react'
|
|
4
|
-
import type { SceneConfig, OrbitControlsConfig, GridHelperConfig } from '@linxai/3d-shared'
|
|
5
|
-
import type { SceneBuilder as ISceneBuilder } from '@linxai/3d-shared/internal'
|
|
6
|
-
import { SceneContent } from '@linxai/3d-shared'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Web 端场景构建器
|
|
10
|
-
* 装配 Web 平台特定的 Canvas 和 OrbitControls
|
|
11
|
-
*/
|
|
12
|
-
export const WebSceneBuilder: ISceneBuilder = {
|
|
13
|
-
/**
|
|
14
|
-
* 构建 Web 平台的 Canvas
|
|
15
|
-
*/
|
|
16
|
-
buildCanvas: ({ children, config = {}, style, className }) => {
|
|
17
|
-
const {
|
|
18
|
-
cameraPosition = [0, 0, 5],
|
|
19
|
-
fov = 75,
|
|
20
|
-
} = config
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<Canvas
|
|
24
|
-
camera={{ position: cameraPosition, fov }}
|
|
25
|
-
style={style as CSSProperties}
|
|
26
|
-
className={className}
|
|
27
|
-
>
|
|
28
|
-
<SceneContent config={config}>
|
|
29
|
-
{children}
|
|
30
|
-
</SceneContent>
|
|
31
|
-
</Canvas>
|
|
32
|
-
)
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 构建 Web 平台的 OrbitControls
|
|
37
|
-
*/
|
|
38
|
-
buildOrbitControls: (config?: OrbitControlsConfig) => {
|
|
39
|
-
const {
|
|
40
|
-
enableDamping = true,
|
|
41
|
-
dampingFactor = 0.05,
|
|
42
|
-
enableRotate = true,
|
|
43
|
-
enableZoom = true,
|
|
44
|
-
enablePan = true,
|
|
45
|
-
minDistance,
|
|
46
|
-
maxDistance,
|
|
47
|
-
minPolarAngle,
|
|
48
|
-
maxPolarAngle,
|
|
49
|
-
target,
|
|
50
|
-
} = config || {}
|
|
51
|
-
|
|
52
|
-
return (
|
|
53
|
-
<OrbitControls
|
|
54
|
-
enableDamping={enableDamping}
|
|
55
|
-
dampingFactor={dampingFactor}
|
|
56
|
-
enableRotate={enableRotate}
|
|
57
|
-
enableZoom={enableZoom}
|
|
58
|
-
enablePan={enablePan}
|
|
59
|
-
minDistance={minDistance}
|
|
60
|
-
maxDistance={maxDistance}
|
|
61
|
-
minPolarAngle={minPolarAngle}
|
|
62
|
-
maxPolarAngle={maxPolarAngle}
|
|
63
|
-
target={target}
|
|
64
|
-
/>
|
|
65
|
-
)
|
|
66
|
-
},
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 构建 Web 平台的 GridHelper
|
|
70
|
-
*/
|
|
71
|
-
buildGridHelper: (config?: GridHelperConfig) => {
|
|
72
|
-
if (!config || config?.visible === false) {
|
|
73
|
-
return null
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const {
|
|
77
|
-
size = 10,
|
|
78
|
-
divisions = 10,
|
|
79
|
-
centerLineColor = '#888888',
|
|
80
|
-
gridColor = '#444444',
|
|
81
|
-
} = config || {}
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
// @ts-ignore - R3F JSX 元素
|
|
85
|
-
<gridHelper
|
|
86
|
-
args={[size, divisions, centerLineColor, gridColor]}
|
|
87
|
-
/>
|
|
88
|
-
)
|
|
89
|
-
},
|
|
90
|
-
}
|
|
91
|
-
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import { Subtitle } from './Subtitle'
|
|
2
|
-
|
|
3
|
-
export interface CharacterOverlayProps {
|
|
4
|
-
/** 是否显示浮层 */
|
|
5
|
-
visible?: boolean
|
|
6
|
-
/** 当前字幕文本 */
|
|
7
|
-
subtitle?: string
|
|
8
|
-
/** 是否显示字幕 */
|
|
9
|
-
showSubtitle?: boolean
|
|
10
|
-
/** 是否显示说话按钮 */
|
|
11
|
-
showSpeakButton?: boolean
|
|
12
|
-
/** 是否正在连接/加载 */
|
|
13
|
-
isLoading?: boolean
|
|
14
|
-
/** 是否已连接 */
|
|
15
|
-
isConnected?: boolean
|
|
16
|
-
/** 开始说话回调 */
|
|
17
|
-
onStartSpeak?: () => void
|
|
18
|
-
/** 停止说话回调 */
|
|
19
|
-
onStopSpeak?: () => void
|
|
20
|
-
/** 按钮点击回调(用于外部控制连接状态) */
|
|
21
|
-
onButtonClick?: () => void
|
|
22
|
-
/** 字幕样式 */
|
|
23
|
-
subtitleStyle?: React.CSSProperties
|
|
24
|
-
/** 容器样式 */
|
|
25
|
-
style?: React.CSSProperties
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 角色浮层组件
|
|
30
|
-
* 包含说话控制按钮和字幕显示
|
|
31
|
-
*
|
|
32
|
-
* 纯 UI 组件,基于 props 驱动,不包含业务逻辑
|
|
33
|
-
*/
|
|
34
|
-
export function CharacterOverlay({
|
|
35
|
-
visible = true,
|
|
36
|
-
subtitle = '',
|
|
37
|
-
showSubtitle = true,
|
|
38
|
-
showSpeakButton = true,
|
|
39
|
-
isLoading = false,
|
|
40
|
-
isConnected = false,
|
|
41
|
-
onStartSpeak,
|
|
42
|
-
onStopSpeak,
|
|
43
|
-
onButtonClick,
|
|
44
|
-
subtitleStyle,
|
|
45
|
-
style,
|
|
46
|
-
}: CharacterOverlayProps) {
|
|
47
|
-
if (!visible) return null
|
|
48
|
-
|
|
49
|
-
const handleSpeakClick = () => {
|
|
50
|
-
// 优先使用外部提供的按钮点击回调
|
|
51
|
-
if (onButtonClick) {
|
|
52
|
-
onButtonClick()
|
|
53
|
-
} else {
|
|
54
|
-
// 回退到传统的 start/stop 回调
|
|
55
|
-
if (isConnected) {
|
|
56
|
-
onStopSpeak?.()
|
|
57
|
-
} else {
|
|
58
|
-
onStartSpeak?.()
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<div
|
|
65
|
-
style={{
|
|
66
|
-
position: 'absolute',
|
|
67
|
-
top: 0,
|
|
68
|
-
left: 0,
|
|
69
|
-
right: 0,
|
|
70
|
-
bottom: 0,
|
|
71
|
-
pointerEvents: 'none',
|
|
72
|
-
display: 'flex',
|
|
73
|
-
flexDirection: 'column',
|
|
74
|
-
justifyContent: 'flex-end',
|
|
75
|
-
padding: '20px',
|
|
76
|
-
zIndex: 100,
|
|
77
|
-
...style,
|
|
78
|
-
}}
|
|
79
|
-
>
|
|
80
|
-
{/* 字幕组件 */}
|
|
81
|
-
<Subtitle
|
|
82
|
-
text={subtitle}
|
|
83
|
-
visible={showSubtitle}
|
|
84
|
-
boxStyle={subtitleStyle}
|
|
85
|
-
bottomOffset={0}
|
|
86
|
-
style={{ position: 'relative', bottom: 'auto', marginBottom: '80px' }}
|
|
87
|
-
/>
|
|
88
|
-
|
|
89
|
-
{/* 说话按钮 */}
|
|
90
|
-
{showSpeakButton && (
|
|
91
|
-
<div
|
|
92
|
-
style={{
|
|
93
|
-
display: 'flex',
|
|
94
|
-
justifyContent: 'center',
|
|
95
|
-
}}
|
|
96
|
-
>
|
|
97
|
-
<button
|
|
98
|
-
onClick={handleSpeakClick}
|
|
99
|
-
disabled={isLoading}
|
|
100
|
-
style={{
|
|
101
|
-
pointerEvents: 'auto',
|
|
102
|
-
padding: '14px 28px',
|
|
103
|
-
background: isConnected
|
|
104
|
-
? 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)'
|
|
105
|
-
: 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)',
|
|
106
|
-
color: '#fff',
|
|
107
|
-
border: 'none',
|
|
108
|
-
borderRadius: '50px',
|
|
109
|
-
cursor: isLoading ? 'wait' : 'pointer',
|
|
110
|
-
fontSize: '15px',
|
|
111
|
-
fontWeight: '600',
|
|
112
|
-
display: 'flex',
|
|
113
|
-
alignItems: 'center',
|
|
114
|
-
gap: '8px',
|
|
115
|
-
boxShadow: isConnected || isLoading
|
|
116
|
-
? '0 4px 20px rgba(239, 68, 68, 0.4)'
|
|
117
|
-
: '0 4px 20px rgba(139, 92, 246, 0.4)',
|
|
118
|
-
transition: 'all 0.3s ease',
|
|
119
|
-
transform: 'scale(1)',
|
|
120
|
-
opacity: isLoading ? 0.7 : 1,
|
|
121
|
-
}}
|
|
122
|
-
onMouseEnter={(e) => {
|
|
123
|
-
if (!isLoading) {
|
|
124
|
-
e.currentTarget.style.transform = 'scale(1.05)'
|
|
125
|
-
e.currentTarget.style.boxShadow = isConnected
|
|
126
|
-
? '0 6px 25px rgba(239, 68, 68, 0.5)'
|
|
127
|
-
: '0 6px 25px rgba(139, 92, 246, 0.5)'
|
|
128
|
-
}
|
|
129
|
-
}}
|
|
130
|
-
onMouseLeave={(e) => {
|
|
131
|
-
e.currentTarget.style.transform = 'scale(1)'
|
|
132
|
-
e.currentTarget.style.boxShadow = isConnected || isLoading
|
|
133
|
-
? '0 4px 20px rgba(239, 68, 68, 0.4)'
|
|
134
|
-
: '0 4px 20px rgba(139, 92, 246, 0.4)'
|
|
135
|
-
}}
|
|
136
|
-
onMouseDown={(e) => {
|
|
137
|
-
if (!isLoading) {
|
|
138
|
-
e.currentTarget.style.transform = 'scale(0.95)'
|
|
139
|
-
}
|
|
140
|
-
}}
|
|
141
|
-
onMouseUp={(e) => {
|
|
142
|
-
if (!isLoading) {
|
|
143
|
-
e.currentTarget.style.transform = 'scale(1.05)'
|
|
144
|
-
}
|
|
145
|
-
}}
|
|
146
|
-
>
|
|
147
|
-
<span style={{ fontSize: '18px' }}>
|
|
148
|
-
{isLoading ? '⏳' : isConnected ? '🔴' : '💬'}
|
|
149
|
-
</span>
|
|
150
|
-
<span>
|
|
151
|
-
{isLoading ? '连接中...' : isConnected ? '结束对话' : '开始说话'}
|
|
152
|
-
</span>
|
|
153
|
-
{(isConnected || isLoading) && (
|
|
154
|
-
<span
|
|
155
|
-
style={{
|
|
156
|
-
width: '8px',
|
|
157
|
-
height: '8px',
|
|
158
|
-
borderRadius: '50%',
|
|
159
|
-
background: '#fff',
|
|
160
|
-
animation: 'pulse 1.5s ease-in-out infinite',
|
|
161
|
-
}}
|
|
162
|
-
/>
|
|
163
|
-
)}
|
|
164
|
-
</button>
|
|
165
|
-
</div>
|
|
166
|
-
)}
|
|
167
|
-
|
|
168
|
-
{/* 添加动画样式 */}
|
|
169
|
-
<style>{`
|
|
170
|
-
@keyframes pulse {
|
|
171
|
-
0%, 100% {
|
|
172
|
-
opacity: 1;
|
|
173
|
-
transform: scale(1);
|
|
174
|
-
}
|
|
175
|
-
50% {
|
|
176
|
-
opacity: 0.5;
|
|
177
|
-
transform: scale(0.8);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
`}</style>
|
|
181
|
-
</div>
|
|
182
|
-
)
|
|
183
|
-
}
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { Suspense, useRef, useEffect, useCallback, useState } from 'react'
|
|
2
|
-
import type { CharacterViewProps, Character } from '@linxai/3d-shared'
|
|
3
|
-
import { useCharacterController } from '@linxai/3d-shared/internal'
|
|
4
|
-
import { Scene3D } from './Scene3D'
|
|
5
|
-
import { CharacterOverlay } from './CharacterOverlay'
|
|
6
|
-
import { WebModelLoaderAdapter } from '../adapters/ModelLoaderAdapter'
|
|
7
|
-
import { WebAnimationLoaderAdapter } from '../adapters/AnimationLoaderAdapter'
|
|
8
|
-
|
|
9
|
-
// 重新导出类型
|
|
10
|
-
export type { CharacterViewProps } from '@linxai/3d-shared'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* 3D 角色查看器内容(在 Canvas 内部)
|
|
14
|
-
*/
|
|
15
|
-
function CharacterContent({
|
|
16
|
-
model,
|
|
17
|
-
animationStates,
|
|
18
|
-
initialState,
|
|
19
|
-
initialMood,
|
|
20
|
-
enableWelcome,
|
|
21
|
-
onError,
|
|
22
|
-
onReady,
|
|
23
|
-
onStateChange,
|
|
24
|
-
children,
|
|
25
|
-
}: Pick<CharacterViewProps, 'model' | 'animationStates' | 'initialState' | 'initialMood' | 'enableWelcome' | 'onError' | 'onReady' | 'onStateChange' | 'children'>) {
|
|
26
|
-
|
|
27
|
-
const { loadedModel, character, isReady } = useCharacterController({
|
|
28
|
-
model,
|
|
29
|
-
modelAdapter: WebModelLoaderAdapter,
|
|
30
|
-
animationAdapter: WebAnimationLoaderAdapter,
|
|
31
|
-
animationStates,
|
|
32
|
-
initialState,
|
|
33
|
-
initialMood,
|
|
34
|
-
enableWelcome,
|
|
35
|
-
onError,
|
|
36
|
-
onStateChange,
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
// 当 character 准备好时通知外部(只在 character 改变时触发)
|
|
40
|
-
const onReadyRef = useRef(onReady)
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
onReadyRef.current = onReady
|
|
43
|
-
}, [onReady])
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
if (character) {
|
|
47
|
-
onReadyRef.current?.(character)
|
|
48
|
-
}
|
|
49
|
-
}, [character])
|
|
50
|
-
|
|
51
|
-
// 控制模型可见性
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (loadedModel?.scene) {
|
|
54
|
-
loadedModel.scene.visible = isReady
|
|
55
|
-
}
|
|
56
|
-
}, [loadedModel, isReady])
|
|
57
|
-
|
|
58
|
-
if (!loadedModel) return null
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
<>
|
|
62
|
-
<primitive object={loadedModel.scene} />
|
|
63
|
-
{children}
|
|
64
|
-
</>
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* 3D 角色查看器
|
|
70
|
-
*/
|
|
71
|
-
export function CharacterView(props: CharacterViewProps) {
|
|
72
|
-
const {
|
|
73
|
-
model,
|
|
74
|
-
animationStates,
|
|
75
|
-
initialState,
|
|
76
|
-
initialMood,
|
|
77
|
-
enableWelcome,
|
|
78
|
-
sceneConfig,
|
|
79
|
-
controlsConfig,
|
|
80
|
-
overlay,
|
|
81
|
-
style,
|
|
82
|
-
className,
|
|
83
|
-
children,
|
|
84
|
-
onError,
|
|
85
|
-
onReady,
|
|
86
|
-
onStateChange,
|
|
87
|
-
} = props
|
|
88
|
-
|
|
89
|
-
// 内部管理字幕状态
|
|
90
|
-
const [subtitle, setSubtitle] = useState('')
|
|
91
|
-
const characterRef = useRef<Character | null>(null)
|
|
92
|
-
|
|
93
|
-
// 字幕控制方法
|
|
94
|
-
const appendSubtitle = useCallback((text: string) => {
|
|
95
|
-
setSubtitle(prev => prev + text)
|
|
96
|
-
}, [])
|
|
97
|
-
|
|
98
|
-
const replaceSubtitle = useCallback((text: string) => {
|
|
99
|
-
setSubtitle(text)
|
|
100
|
-
}, [])
|
|
101
|
-
|
|
102
|
-
const clearSubtitle = useCallback(() => {
|
|
103
|
-
setSubtitle('')
|
|
104
|
-
}, [])
|
|
105
|
-
|
|
106
|
-
const handleReady = useCallback((character: Character) => {
|
|
107
|
-
// 直接在 character 对象上添加字幕控制方法
|
|
108
|
-
character.appendSubtitle = appendSubtitle
|
|
109
|
-
character.setSubtitle = replaceSubtitle
|
|
110
|
-
character.clearSubtitle = clearSubtitle
|
|
111
|
-
|
|
112
|
-
characterRef.current = character
|
|
113
|
-
onReady?.(character)
|
|
114
|
-
}, [onReady, appendSubtitle, replaceSubtitle, clearSubtitle])
|
|
115
|
-
|
|
116
|
-
const handleStartSpeak = useCallback(() => {
|
|
117
|
-
characterRef.current?.startSpeak()
|
|
118
|
-
}, [])
|
|
119
|
-
|
|
120
|
-
const handleStopSpeak = useCallback(() => {
|
|
121
|
-
characterRef.current?.stopSpeak()
|
|
122
|
-
}, [])
|
|
123
|
-
|
|
124
|
-
return (
|
|
125
|
-
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
126
|
-
<Scene3D
|
|
127
|
-
config={sceneConfig}
|
|
128
|
-
controlsConfig={controlsConfig}
|
|
129
|
-
style={style}
|
|
130
|
-
className={className}
|
|
131
|
-
showProgress={false}
|
|
132
|
-
>
|
|
133
|
-
<Suspense fallback={null}>
|
|
134
|
-
<CharacterContent
|
|
135
|
-
model={model}
|
|
136
|
-
animationStates={animationStates}
|
|
137
|
-
initialState={initialState}
|
|
138
|
-
initialMood={initialMood}
|
|
139
|
-
enableWelcome={enableWelcome}
|
|
140
|
-
onError={onError}
|
|
141
|
-
onReady={handleReady}
|
|
142
|
-
onStateChange={onStateChange}
|
|
143
|
-
>
|
|
144
|
-
{children}
|
|
145
|
-
</CharacterContent>
|
|
146
|
-
</Suspense>
|
|
147
|
-
</Scene3D>
|
|
148
|
-
|
|
149
|
-
{overlay?.show && (
|
|
150
|
-
<CharacterOverlay
|
|
151
|
-
visible={true}
|
|
152
|
-
subtitle={subtitle}
|
|
153
|
-
showSpeakButton={overlay.showSpeakButton ?? true}
|
|
154
|
-
isLoading={overlay.isLoading}
|
|
155
|
-
isConnected={overlay.isConnected}
|
|
156
|
-
onStartSpeak={handleStartSpeak}
|
|
157
|
-
onStopSpeak={handleStopSpeak}
|
|
158
|
-
onButtonClick={overlay.onButtonClick}
|
|
159
|
-
subtitleStyle={overlay.subtitleStyle}
|
|
160
|
-
style={overlay.style}
|
|
161
|
-
/>
|
|
162
|
-
)}
|
|
163
|
-
</div>
|
|
164
|
-
)
|
|
165
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { CSSProperties } from 'react'
|
|
2
|
-
|
|
3
|
-
export interface ProgressBarProps {
|
|
4
|
-
/** 进度值 (0-1) */
|
|
5
|
-
progress: number
|
|
6
|
-
/** 容器样式 */
|
|
7
|
-
style?: CSSProperties
|
|
8
|
-
/** 进度条颜色 */
|
|
9
|
-
color?: string
|
|
10
|
-
/** 背景颜色 */
|
|
11
|
-
backgroundColor?: string
|
|
12
|
-
/** 高度 */
|
|
13
|
-
height?: number
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 简单的进度条组件 (Web)
|
|
18
|
-
*/
|
|
19
|
-
export function ProgressBar({
|
|
20
|
-
progress,
|
|
21
|
-
style,
|
|
22
|
-
color = '#007AFF',
|
|
23
|
-
backgroundColor = '#E5E5E5',
|
|
24
|
-
height = 8,
|
|
25
|
-
}: ProgressBarProps) {
|
|
26
|
-
const percentage = Math.round(Math.max(0, Math.min(1, progress)) * 100)
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<div
|
|
30
|
-
style={{
|
|
31
|
-
width: '100%',
|
|
32
|
-
padding: 8,
|
|
33
|
-
backgroundColor: '#2a2a2a',
|
|
34
|
-
borderRadius: 8,
|
|
35
|
-
...style,
|
|
36
|
-
}}
|
|
37
|
-
>
|
|
38
|
-
<div
|
|
39
|
-
style={{
|
|
40
|
-
width: '100%',
|
|
41
|
-
height,
|
|
42
|
-
backgroundColor,
|
|
43
|
-
borderRadius: 4,
|
|
44
|
-
overflow: 'hidden',
|
|
45
|
-
}}
|
|
46
|
-
>
|
|
47
|
-
<div
|
|
48
|
-
style={{
|
|
49
|
-
width: `${percentage}%`,
|
|
50
|
-
height: '100%',
|
|
51
|
-
backgroundColor: color,
|
|
52
|
-
transition: 'width 0.3s ease',
|
|
53
|
-
}}
|
|
54
|
-
/>
|
|
55
|
-
</div>
|
|
56
|
-
<div
|
|
57
|
-
style={{
|
|
58
|
-
marginTop: 8,
|
|
59
|
-
fontSize: 14,
|
|
60
|
-
fontWeight: 'bold',
|
|
61
|
-
color: '#ffffff',
|
|
62
|
-
}}
|
|
63
|
-
>
|
|
64
|
-
{percentage}%
|
|
65
|
-
</div>
|
|
66
|
-
</div>
|
|
67
|
-
)
|
|
68
|
-
}
|
|
69
|
-
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import type { ReactNode, CSSProperties } from 'react'
|
|
2
|
-
import type { SceneConfig, OrbitControlsConfig } from '@linxai/3d-shared'
|
|
3
|
-
import { LoadingProvider, useLoading } from '@linxai/3d-shared/internal'
|
|
4
|
-
import { ProgressBar } from './ProgressBar'
|
|
5
|
-
import { WebSceneBuilder } from '../builders/SceneBuilder'
|
|
6
|
-
|
|
7
|
-
interface Scene3DProps {
|
|
8
|
-
/** 子组件 */
|
|
9
|
-
children: ReactNode
|
|
10
|
-
/** 场景配置 */
|
|
11
|
-
config?: SceneConfig
|
|
12
|
-
/** OrbitControls 配置 */
|
|
13
|
-
controlsConfig?: OrbitControlsConfig
|
|
14
|
-
/** Canvas 样式 */
|
|
15
|
-
style?: CSSProperties
|
|
16
|
-
/** Canvas className */
|
|
17
|
-
className?: string
|
|
18
|
-
/** 是否显示进度条 */
|
|
19
|
-
showProgress?: boolean
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Web 端 3D 场景容器
|
|
24
|
-
* 使用装配式架构,通过 WebSceneBuilder 装配 Canvas 和 OrbitControls
|
|
25
|
-
* 支持 React Suspense 配合加载进度显示
|
|
26
|
-
*
|
|
27
|
-
* @example
|
|
28
|
-
* ```tsx
|
|
29
|
-
* <Scene3D
|
|
30
|
-
* config={{ backgroundColor: '#000' }}
|
|
31
|
-
* controlsConfig={{ enableRotate: true }}
|
|
32
|
-
* >
|
|
33
|
-
* <CharacterView model={{ path: "/model.gltf" }} />
|
|
34
|
-
* </Scene3D>
|
|
35
|
-
* ```
|
|
36
|
-
*/
|
|
37
|
-
function Scene3DContent({
|
|
38
|
-
children,
|
|
39
|
-
config = {},
|
|
40
|
-
controlsConfig,
|
|
41
|
-
style,
|
|
42
|
-
className,
|
|
43
|
-
showProgress = true,
|
|
44
|
-
}: Scene3DProps) {
|
|
45
|
-
const { progress, isLoading } = useLoading()
|
|
46
|
-
const canvas = WebSceneBuilder.buildCanvas({
|
|
47
|
-
children: (
|
|
48
|
-
<>
|
|
49
|
-
{WebSceneBuilder.buildOrbitControls(controlsConfig)}
|
|
50
|
-
{WebSceneBuilder.buildGridHelper(config.gridHelper)}
|
|
51
|
-
{children}
|
|
52
|
-
</>
|
|
53
|
-
),
|
|
54
|
-
config,
|
|
55
|
-
style,
|
|
56
|
-
className,
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
const shouldShowProgress = showProgress && isLoading && progress < 1
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
|
63
|
-
{canvas}
|
|
64
|
-
{shouldShowProgress && (
|
|
65
|
-
<div
|
|
66
|
-
style={{
|
|
67
|
-
position: 'absolute',
|
|
68
|
-
top: '50%',
|
|
69
|
-
left: '50%',
|
|
70
|
-
transform: 'translate(-50%, -50%)',
|
|
71
|
-
width: '80%',
|
|
72
|
-
maxWidth: 400,
|
|
73
|
-
zIndex: 1000,
|
|
74
|
-
}}
|
|
75
|
-
>
|
|
76
|
-
<ProgressBar progress={progress} />
|
|
77
|
-
</div>
|
|
78
|
-
)}
|
|
79
|
-
</div>
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function Scene3D(props: Scene3DProps) {
|
|
84
|
-
return (
|
|
85
|
-
<LoadingProvider>
|
|
86
|
-
<Scene3DContent {...props} />
|
|
87
|
-
</LoadingProvider>
|
|
88
|
-
)
|
|
89
|
-
}
|