virtual-human-cf 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Virtual Human Component Framework (virtual-human-cf)
|
|
2
|
+
|
|
3
|
+
一个基于 Vue 3 的虚拟数字人组件库,用于展示和控制数字人视频,并处理相关音频和交互事件。
|
|
4
|
+
|
|
5
|
+
## 特性
|
|
6
|
+
|
|
7
|
+
- 支持数字人视频播放控制
|
|
8
|
+
- WebSocket 实时通信
|
|
9
|
+
- 音频播放处理
|
|
10
|
+
- 事件监听和响应
|
|
11
|
+
- 暗黑模式支持
|
|
12
|
+
- 响应式设计
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install virtual-human-cf
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 使用方法
|
|
21
|
+
|
|
22
|
+
### 全局注册
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
import { createApp } from 'vue';
|
|
26
|
+
import App from './App.vue';
|
|
27
|
+
import VirtualHumanCF from 'virtual-human-cf';
|
|
28
|
+
|
|
29
|
+
const app = createApp(App);
|
|
30
|
+
app.use(VirtualHumanCF);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 按需导入
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
import { VirtualHumanPersona, VirtualHumanEventAdapter } from 'virtual-human-cf';
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 组件说明
|
|
40
|
+
|
|
41
|
+
### VirtualHumanPersona
|
|
42
|
+
|
|
43
|
+
数字人视频展示组件,负责视频播放和状态控制。
|
|
44
|
+
|
|
45
|
+
#### Props
|
|
46
|
+
|
|
47
|
+
| 属性 | 类型 | 默认值 | 必填 | 说明 |
|
|
48
|
+
|------|------|--------|------|------|
|
|
49
|
+
| videoSrc | String | - | 是 | 视频源 URL |
|
|
50
|
+
| visible | Boolean | true | 否 | 是否可见 |
|
|
51
|
+
| isPlaying | Boolean | false | 否 | 是否正在播放 |
|
|
52
|
+
| muted | Boolean | true | 否 | 是否静音 |
|
|
53
|
+
| isDark | Boolean | false | 否 | 是否暗黑模式 |
|
|
54
|
+
| screenClientId | String | - | 否 | 屏幕客户端 ID |
|
|
55
|
+
| wsUrl | String | - | 否 | WebSocket 连接地址 |
|
|
56
|
+
|
|
57
|
+
#### Events
|
|
58
|
+
|
|
59
|
+
| 事件名 | 说明 | 回调参数 |
|
|
60
|
+
|--------|------|----------|
|
|
61
|
+
| update:isPlaying | 播放状态变更 | 播放状态(Boolean) |
|
|
62
|
+
| update:visible | 可见性变更 | 可见状态(Boolean) |
|
|
63
|
+
| ended | 播放结束 | - |
|
|
64
|
+
|
|
65
|
+
#### 示例
|
|
66
|
+
|
|
67
|
+
```vue
|
|
68
|
+
<template>
|
|
69
|
+
<VirtualHumanPersona
|
|
70
|
+
:video-src="videoUrl"
|
|
71
|
+
:is-playing="playing"
|
|
72
|
+
:muted="true"
|
|
73
|
+
:is-dark="false"
|
|
74
|
+
:screen-client-id="clientId"
|
|
75
|
+
:ws-url="websocketUrl"
|
|
76
|
+
@update:isPlaying="onPlayingChange"
|
|
77
|
+
@ended="onEnded"
|
|
78
|
+
/>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<script setup>
|
|
82
|
+
import { ref } from 'vue';
|
|
83
|
+
import { VirtualHumanPersona } from 'virtual-human-cf';
|
|
84
|
+
|
|
85
|
+
const videoUrl = ref('https://example.com/video.mp4');
|
|
86
|
+
const playing = ref(false);
|
|
87
|
+
const clientId = ref('screen-123');
|
|
88
|
+
const websocketUrl = ref('ws://localhost:8080/ws');
|
|
89
|
+
|
|
90
|
+
const onPlayingChange = (isPlaying) => {
|
|
91
|
+
console.log('播放状态改变:', isPlaying);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const onEnded = () => {
|
|
95
|
+
console.log('播放结束');
|
|
96
|
+
};
|
|
97
|
+
</script>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### VirtualHumanEventAdapter
|
|
101
|
+
|
|
102
|
+
数字人事件适配器组件,负责处理 WebSocket 消息、音频播放和各种交互事件。
|
|
103
|
+
|
|
104
|
+
#### Props
|
|
105
|
+
|
|
106
|
+
| 属性 | 类型 | 默认值 | 必填 | 说明 |
|
|
107
|
+
|------|------|--------|------|------|
|
|
108
|
+
| screenClientId | String | - | 是 | 屏幕客户端 ID |
|
|
109
|
+
| wsUrl | String | - | 是 | WebSocket 连接地址 |
|
|
110
|
+
|
|
111
|
+
#### Events
|
|
112
|
+
|
|
113
|
+
| 事件名 | 说明 | 回调参数 |
|
|
114
|
+
|--------|------|----------|
|
|
115
|
+
| connected | WebSocket 连接成功 | - |
|
|
116
|
+
| highlight | 高亮大屏某区域 | 载荷数据 |
|
|
117
|
+
| showDialog | 显示弹窗 | 载荷数据 |
|
|
118
|
+
| end | 数字人对话结束 | 载荷数据 |
|
|
119
|
+
| pause | 暂停播放 | 载荷数据 |
|
|
120
|
+
| error | 错误事件 | 错误信息 |
|
|
121
|
+
|
|
122
|
+
#### 示例
|
|
123
|
+
|
|
124
|
+
```vue
|
|
125
|
+
<template>
|
|
126
|
+
<VirtualHumanEventAdapter
|
|
127
|
+
:screen-client-id="clientId"
|
|
128
|
+
:ws-url="websocketUrl"
|
|
129
|
+
@connected="onConnected"
|
|
130
|
+
@highlight="onHighlight"
|
|
131
|
+
@showDialog="onShowDialog"
|
|
132
|
+
@end="onEnd"
|
|
133
|
+
@error="onError"
|
|
134
|
+
>
|
|
135
|
+
<div>其他内容</div>
|
|
136
|
+
</VirtualHumanEventAdapter>
|
|
137
|
+
</template>
|
|
138
|
+
|
|
139
|
+
<script setup>
|
|
140
|
+
import { VirtualHumanEventAdapter } from 'virtual-human-cf';
|
|
141
|
+
|
|
142
|
+
const clientId = 'screen-123';
|
|
143
|
+
const websocketUrl = 'ws://localhost:8080/ws';
|
|
144
|
+
|
|
145
|
+
const onConnected = () => {
|
|
146
|
+
console.log('WebSocket 连接成功');
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const onHighlight = (payload) => {
|
|
150
|
+
console.log('高亮事件:', payload);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const onShowDialog = (payload) => {
|
|
154
|
+
console.log('显示弹窗:', payload);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const onEnd = (payload) => {
|
|
158
|
+
console.log('对话结束:', payload);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const onError = (error) => {
|
|
162
|
+
console.error('错误:', error);
|
|
163
|
+
};
|
|
164
|
+
</script>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## 完整示例
|
|
168
|
+
|
|
169
|
+
以下是一个结合两个组件使用的完整示例:
|
|
170
|
+
|
|
171
|
+
```vue
|
|
172
|
+
<template>
|
|
173
|
+
<div class="container">
|
|
174
|
+
<VirtualHumanPersona
|
|
175
|
+
:video-src="videoUrl"
|
|
176
|
+
:is-playing="isPlaying"
|
|
177
|
+
:muted="true"
|
|
178
|
+
:is-dark="isDarkMode"
|
|
179
|
+
:screen-client-id="clientId"
|
|
180
|
+
:ws-url="websocketUrl"
|
|
181
|
+
@update:isPlaying="(val) => isPlaying = val"
|
|
182
|
+
@ended="onVideoEnded"
|
|
183
|
+
/>
|
|
184
|
+
|
|
185
|
+
<VirtualHumanEventAdapter
|
|
186
|
+
:screen-client-id="clientId"
|
|
187
|
+
:ws-url="websocketUrl"
|
|
188
|
+
@connected="onConnected"
|
|
189
|
+
@highlight="onHighlight"
|
|
190
|
+
@showDialog="onShowDialog"
|
|
191
|
+
@end="onEnd"
|
|
192
|
+
@error="onError"
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
</template>
|
|
196
|
+
|
|
197
|
+
<script setup>
|
|
198
|
+
import { ref } from 'vue';
|
|
199
|
+
import { VirtualHumanPersona, VirtualHumanEventAdapter } from 'virtual-human-cf';
|
|
200
|
+
|
|
201
|
+
const videoUrl = ref('/path/to/digital-human-video.mp4');
|
|
202
|
+
const isPlaying = ref(false);
|
|
203
|
+
const isDarkMode = ref(false);
|
|
204
|
+
const clientId = ref('screen-123');
|
|
205
|
+
const websocketUrl = ref('ws://localhost:8080/ws');
|
|
206
|
+
|
|
207
|
+
const onConnected = () => {
|
|
208
|
+
console.log('连接到 WebSocket 服务器');
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const onVideoEnded = () => {
|
|
212
|
+
console.log('视频播放结束');
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const onHighlight = (payload) => {
|
|
216
|
+
console.log('收到高亮指令:', payload);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const onShowDialog = (payload) => {
|
|
220
|
+
console.log('显示对话框:', payload);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const onEnd = (payload) => {
|
|
224
|
+
console.log('数字人交互结束:', payload);
|
|
225
|
+
isPlaying.value = false;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const onError = (error) => {
|
|
229
|
+
console.error('发生错误:', error);
|
|
230
|
+
};
|
|
231
|
+
</script>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## 开发
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
# 安装依赖
|
|
238
|
+
npm install
|
|
239
|
+
|
|
240
|
+
# 构建项目
|
|
241
|
+
npm run build
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## 发布
|
|
245
|
+
|
|
246
|
+
在发布之前,请确保按照 npm 包发布安全与流程规范操作:
|
|
247
|
+
|
|
248
|
+
1. 确保项目已通过 `npm run build` 成功构建
|
|
249
|
+
2. 检查 package.json 中版本号是否更新
|
|
250
|
+
3. 登录 npm 账户并使用令牌认证
|
|
251
|
+
4. 确保 registry 指向官方源
|
|
252
|
+
|
|
253
|
+
## 许可证
|
|
254
|
+
|
|
255
|
+
MIT
|
package/dist/style.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.fade-enter-active[data-v-
|
|
1
|
+
.fade-enter-active[data-v-2fb9caca],.fade-leave-active[data-v-2fb9caca]{transition:opacity .5s ease,transform .5s ease}.fade-enter-from[data-v-2fb9caca],.fade-leave-to[data-v-2fb9caca]{opacity:0;transform:translateY(10px)}.virtual-human-container[data-v-2fb9caca]{position:relative;width:100%;max-width:400px;border-radius:1rem;overflow:hidden;box-shadow:0 10px 25px #0000001a;background:#fff;transition:background-color .3s ease}.virtual-human-container.is-dark[data-v-2fb9caca]{background:#1f2937;box-shadow:0 10px 25px #00000080}.video-wrapper[data-v-2fb9caca]{position:relative;width:100%;aspect-ratio:9 / 16;background:#000;overflow:visible}.persona-video[data-v-2fb9caca]{width:100%;height:100%;object-fit:cover;transform-origin:center;transition:transform .1s ease-out}.resize-handle[data-v-2fb9caca]{position:absolute;width:20px;height:20px;background:#00000080;border:2px solid rgba(255,255,255,.8);border-radius:4px;cursor:pointer;z-index:20;transition:background .2s,border-color .2s}.resize-handle[data-v-2fb9caca]:hover{background:#000000b3;border-color:#fff}.resize-handle.top-left[data-v-2fb9caca]{top:-10px;left:-10px;cursor:nwse-resize}.resize-handle.top-right[data-v-2fb9caca]{top:-10px;right:-10px;cursor:nesw-resize}.resize-handle.bottom-left[data-v-2fb9caca]{bottom:-10px;left:-10px;cursor:nesw-resize}.resize-handle.bottom-right[data-v-2fb9caca]{bottom:-10px;right:-10px;cursor:nwse-resize}.overlay[data-v-2fb9caca]{position:absolute;top:1rem;right:1rem;z-index:10}.status-badge[data-v-2fb9caca]{display:flex;align-items:center;gap:.5rem;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500;color:#fff;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.status-badge.paused[data-v-2fb9caca]{background:#ef4444cc}.status-badge.playing[data-v-2fb9caca]{background:#22c55ecc}.dot[data-v-2fb9caca]{width:6px;height:6px;border-radius:50%;background-color:#fff}@keyframes pulse-2fb9caca{0%,to{opacity:1}50%{opacity:.5}}.animate-pulse[data-v-2fb9caca]{animation:pulse-2fb9caca 2s cubic-bezier(.4,0,.6,1) infinite}
|
|
@@ -1,166 +1,216 @@
|
|
|
1
|
-
import { defineComponent as
|
|
2
|
-
const
|
|
1
|
+
import { defineComponent as T, ref as v, watch as z, onMounted as W, onUnmounted as X, openBlock as h, createBlock as D, Transition as F, withCtx as q, createElementBlock as b, normalizeClass as N, createElementVNode as H, normalizeStyle as O, createCommentVNode as I, createTextVNode as L, renderSlot as J } from "vue";
|
|
2
|
+
const j = ["src", "muted"], G = { class: "overlay" }, K = {
|
|
3
3
|
key: 0,
|
|
4
4
|
class: "status-badge paused"
|
|
5
|
-
},
|
|
5
|
+
}, Q = {
|
|
6
6
|
key: 1,
|
|
7
7
|
class: "status-badge playing"
|
|
8
|
-
},
|
|
8
|
+
}, Z = /* @__PURE__ */ T({
|
|
9
9
|
__name: "VirtualHumanPersona",
|
|
10
10
|
props: {
|
|
11
|
+
// 视频源URL
|
|
11
12
|
videoSrc: {
|
|
12
13
|
type: String,
|
|
13
14
|
required: !0
|
|
14
15
|
},
|
|
16
|
+
// 是否可见
|
|
15
17
|
visible: {
|
|
16
18
|
type: Boolean,
|
|
17
|
-
default: !
|
|
19
|
+
default: !1
|
|
18
20
|
},
|
|
21
|
+
// 是否自动播放
|
|
19
22
|
isPlaying: {
|
|
20
23
|
type: Boolean,
|
|
21
24
|
default: !1
|
|
22
25
|
},
|
|
26
|
+
// 是否静音
|
|
23
27
|
muted: {
|
|
24
28
|
type: Boolean,
|
|
25
29
|
default: !0
|
|
26
30
|
// Auto-play policies usually require muted
|
|
27
31
|
},
|
|
32
|
+
// 是否暗黑模式
|
|
28
33
|
isDark: {
|
|
29
34
|
type: Boolean,
|
|
30
35
|
default: !1
|
|
31
36
|
},
|
|
37
|
+
// 屏幕客户端ID
|
|
32
38
|
screenClientId: {
|
|
33
39
|
type: String,
|
|
34
40
|
required: !1
|
|
35
41
|
},
|
|
42
|
+
// WebSocket URL
|
|
36
43
|
wsUrl: {
|
|
37
44
|
type: String,
|
|
38
45
|
required: !1
|
|
39
46
|
}
|
|
40
47
|
},
|
|
41
48
|
emits: ["update:isPlaying", "ended", "update:visible"],
|
|
42
|
-
setup(
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
49
|
+
setup(i, { emit: P }) {
|
|
50
|
+
const t = i, s = P, l = v(null), k = v(null), n = v(null), m = v(!1), V = v(1), E = v(!1), p = v(null), C = v(0), x = v(0), o = v(1), u = () => {
|
|
51
|
+
if (n.value && n.value.close(), !(!t.wsUrl || !t.screenClientId))
|
|
45
52
|
try {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
},
|
|
53
|
+
const r = new URL(t.wsUrl);
|
|
54
|
+
r.searchParams.append("sessionId", t.screenClientId + "-persona"), n.value = new WebSocket(r.toString()), n.value.onopen = () => {
|
|
55
|
+
m.value = !0, console.log(`[VirtualHumanPersona] Connected to ${t.wsUrl} for session ${t.screenClientId}-persona`);
|
|
56
|
+
}, n.value.onmessage = (e) => {
|
|
50
57
|
try {
|
|
51
|
-
const a = JSON.parse(
|
|
52
|
-
|
|
58
|
+
const a = JSON.parse(e.data);
|
|
59
|
+
c(a);
|
|
53
60
|
} catch (a) {
|
|
54
|
-
console.error("[VirtualHumanPersona] Failed to parse message:",
|
|
61
|
+
console.error("[VirtualHumanPersona] Failed to parse message:", e.data, a);
|
|
55
62
|
}
|
|
56
|
-
},
|
|
57
|
-
console.error("[VirtualHumanPersona] WebSocket error:",
|
|
58
|
-
},
|
|
59
|
-
|
|
63
|
+
}, n.value.onerror = (e) => {
|
|
64
|
+
console.error("[VirtualHumanPersona] WebSocket error:", e);
|
|
65
|
+
}, n.value.onclose = () => {
|
|
66
|
+
m.value = !1, console.log("[VirtualHumanPersona] WebSocket disconnected");
|
|
60
67
|
};
|
|
61
|
-
} catch (
|
|
62
|
-
console.error("[VirtualHumanPersona] Failed to initialize WebSocket:",
|
|
68
|
+
} catch (r) {
|
|
69
|
+
console.error("[VirtualHumanPersona] Failed to initialize WebSocket:", r);
|
|
63
70
|
}
|
|
64
|
-
},
|
|
65
|
-
const { type:
|
|
66
|
-
|
|
71
|
+
}, c = (r) => {
|
|
72
|
+
const { type: e, action: a } = r;
|
|
73
|
+
e === "control" && (a === "play" ? (s("update:isPlaying", !0), s("update:visible", !0)) : a === "pause" ? s("update:isPlaying", !1) : a === "stop" && (s("update:isPlaying", !1), s("update:visible", !1), l.value && (l.value.currentTime = 0)));
|
|
67
74
|
};
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}),
|
|
71
|
-
|
|
72
|
-
}),
|
|
73
|
-
|
|
75
|
+
z(() => t.screenClientId, () => {
|
|
76
|
+
t.screenClientId && t.wsUrl && u();
|
|
77
|
+
}), z(() => t.wsUrl, () => {
|
|
78
|
+
t.screenClientId && t.wsUrl && u();
|
|
79
|
+
}), z(() => t.isPlaying, (r) => {
|
|
80
|
+
l.value && (r ? l.value.play().catch((e) => console.error("Video play failed:", e)) : l.value.pause());
|
|
74
81
|
});
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
},
|
|
78
|
-
|
|
79
|
-
},
|
|
82
|
+
const w = () => {
|
|
83
|
+
t.isPlaying || s("update:isPlaying", !0);
|
|
84
|
+
}, g = () => {
|
|
85
|
+
t.isPlaying && s("update:isPlaying", !1);
|
|
86
|
+
}, A = () => {
|
|
80
87
|
s("update:isPlaying", !1), s("ended");
|
|
88
|
+
}, f = (r, e) => {
|
|
89
|
+
E.value = !0, p.value = r, o.value = V.value;
|
|
90
|
+
const a = "clientX" in e ? e.clientX : e.touches[0].clientX, S = "clientY" in e ? e.clientY : e.touches[0].clientY;
|
|
91
|
+
C.value = a, x.value = S, document.addEventListener("mousemove", y), document.addEventListener("mouseup", d), document.addEventListener("touchmove", y), document.addEventListener("touchend", d);
|
|
92
|
+
}, y = (r) => {
|
|
93
|
+
if (!E.value || !k.value) return;
|
|
94
|
+
const e = "clientX" in r ? r.clientX : r.touches[0].clientX, a = "clientY" in r ? r.clientY : r.touches[0].clientY, S = e - C.value, M = a - x.value, B = k.value.getBoundingClientRect(), Y = 0.5, $ = 2;
|
|
95
|
+
let U = o.value;
|
|
96
|
+
const R = Math.min(B.width, B.height);
|
|
97
|
+
p.value === "bottom-right" ? U = o.value + (S + M) / R : p.value === "bottom-left" ? U = o.value + (-S + M) / R : p.value === "top-right" ? U = o.value + (S - M) / R : p.value === "top-left" && (U = o.value + (-S - M) / R), V.value = Math.max(Y, Math.min($, U));
|
|
98
|
+
}, d = () => {
|
|
99
|
+
E.value = !1, p.value = null, document.removeEventListener("mousemove", y), document.removeEventListener("mouseup", d), document.removeEventListener("touchmove", y), document.removeEventListener("touchend", d);
|
|
81
100
|
};
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
}),
|
|
85
|
-
|
|
86
|
-
}), (
|
|
87
|
-
default:
|
|
88
|
-
|
|
101
|
+
return W(() => {
|
|
102
|
+
t.isPlaying && l.value && l.value.play().catch((r) => console.error("Video play failed:", r)), t.screenClientId && t.wsUrl && u();
|
|
103
|
+
}), X(() => {
|
|
104
|
+
n.value && n.value.close();
|
|
105
|
+
}), (r, e) => (h(), D(F, { name: "fade" }, {
|
|
106
|
+
default: q(() => [
|
|
107
|
+
i.visible ? (h(), b("div", {
|
|
89
108
|
key: 0,
|
|
90
|
-
class:
|
|
109
|
+
class: N(["virtual-human-container", { "is-dark": i.isDark }])
|
|
91
110
|
}, [
|
|
92
|
-
|
|
93
|
-
|
|
111
|
+
H("div", {
|
|
112
|
+
class: "video-wrapper",
|
|
113
|
+
ref_key: "wrapperRef",
|
|
114
|
+
ref: k
|
|
115
|
+
}, [
|
|
116
|
+
H("video", {
|
|
94
117
|
ref_key: "videoRef",
|
|
95
|
-
ref:
|
|
96
|
-
src:
|
|
118
|
+
ref: l,
|
|
119
|
+
src: i.videoSrc,
|
|
97
120
|
class: "persona-video",
|
|
98
|
-
muted:
|
|
121
|
+
muted: i.muted,
|
|
99
122
|
playsinline: "",
|
|
100
123
|
loop: "",
|
|
101
|
-
onPlay:
|
|
102
|
-
onPause:
|
|
103
|
-
onEnded:
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
onPlay: w,
|
|
125
|
+
onPause: g,
|
|
126
|
+
onEnded: A,
|
|
127
|
+
style: O({ transform: `scale(${V.value})` })
|
|
128
|
+
}, null, 44, j),
|
|
129
|
+
i.visible ? (h(), b("div", {
|
|
130
|
+
key: 0,
|
|
131
|
+
class: "resize-handle top-left",
|
|
132
|
+
onMousedown: e[0] || (e[0] = (a) => f("top-left", a)),
|
|
133
|
+
onTouchstart: e[1] || (e[1] = (a) => f("top-left", a))
|
|
134
|
+
}, null, 32)) : I("", !0),
|
|
135
|
+
i.visible ? (h(), b("div", {
|
|
136
|
+
key: 1,
|
|
137
|
+
class: "resize-handle top-right",
|
|
138
|
+
onMousedown: e[2] || (e[2] = (a) => f("top-right", a)),
|
|
139
|
+
onTouchstart: e[3] || (e[3] = (a) => f("top-right", a))
|
|
140
|
+
}, null, 32)) : I("", !0),
|
|
141
|
+
i.visible ? (h(), b("div", {
|
|
142
|
+
key: 2,
|
|
143
|
+
class: "resize-handle bottom-left",
|
|
144
|
+
onMousedown: e[4] || (e[4] = (a) => f("bottom-left", a)),
|
|
145
|
+
onTouchstart: e[5] || (e[5] = (a) => f("bottom-left", a))
|
|
146
|
+
}, null, 32)) : I("", !0),
|
|
147
|
+
i.visible ? (h(), b("div", {
|
|
148
|
+
key: 3,
|
|
149
|
+
class: "resize-handle bottom-right",
|
|
150
|
+
onMousedown: e[6] || (e[6] = (a) => f("bottom-right", a)),
|
|
151
|
+
onTouchstart: e[7] || (e[7] = (a) => f("bottom-right", a))
|
|
152
|
+
}, null, 32)) : I("", !0),
|
|
153
|
+
H("div", G, [
|
|
154
|
+
i.isPlaying ? (h(), b("div", Q, [...e[9] || (e[9] = [
|
|
155
|
+
H("span", { class: "dot animate-pulse" }, null, -1),
|
|
156
|
+
L(" 播放中 ", -1)
|
|
157
|
+
])])) : (h(), b("div", K, [...e[8] || (e[8] = [
|
|
158
|
+
H("span", { class: "dot" }, null, -1),
|
|
159
|
+
L(" 暂停中 ", -1)
|
|
112
160
|
])]))
|
|
113
161
|
])
|
|
114
|
-
])
|
|
115
|
-
], 2)) :
|
|
162
|
+
], 512)
|
|
163
|
+
], 2)) : I("", !0)
|
|
116
164
|
]),
|
|
117
165
|
_: 1
|
|
118
166
|
}));
|
|
119
167
|
}
|
|
120
|
-
}),
|
|
121
|
-
const
|
|
122
|
-
for (const [s,
|
|
123
|
-
|
|
124
|
-
return
|
|
125
|
-
},
|
|
168
|
+
}), _ = (i, P) => {
|
|
169
|
+
const t = i.__vccOpts || i;
|
|
170
|
+
for (const [s, l] of P)
|
|
171
|
+
t[s] = l;
|
|
172
|
+
return t;
|
|
173
|
+
}, ee = /* @__PURE__ */ _(Z, [["__scopeId", "data-v-2fb9caca"]]), te = /* @__PURE__ */ T({
|
|
126
174
|
__name: "VirtualHumanEventAdapter",
|
|
127
175
|
props: {
|
|
176
|
+
// 屏幕客户端ID
|
|
128
177
|
screenClientId: {
|
|
129
178
|
type: String,
|
|
130
179
|
required: !0
|
|
131
180
|
},
|
|
181
|
+
// WebSocket URL
|
|
132
182
|
wsUrl: {
|
|
133
183
|
type: String,
|
|
134
184
|
required: !0
|
|
135
185
|
}
|
|
136
186
|
},
|
|
137
187
|
emits: ["highlight", "showDialog", "end", "pause", "connected", "error"],
|
|
138
|
-
setup(
|
|
139
|
-
const
|
|
140
|
-
let n = null,
|
|
141
|
-
const
|
|
188
|
+
setup(i, { emit: P }) {
|
|
189
|
+
const t = i, s = P, l = v(null), k = v(!1);
|
|
190
|
+
let n = null, m = 0;
|
|
191
|
+
const V = () => {
|
|
142
192
|
n || (n = new (window.AudioContext || window.webkitAudioContext)({
|
|
143
193
|
sampleRate: 24e3
|
|
144
194
|
})), n.state === "suspended" && n.resume();
|
|
145
|
-
},
|
|
146
|
-
if (
|
|
195
|
+
}, E = (o) => {
|
|
196
|
+
if (V(), !!n)
|
|
147
197
|
try {
|
|
148
|
-
const
|
|
149
|
-
for (let d = 0; d <
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
for (let d = 0; d <
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
} catch (
|
|
159
|
-
console.error("[VirtualHumanEventAdapter] Failed to decode and play audio:",
|
|
198
|
+
const u = window.atob(o), c = u.length, w = new Uint8Array(c);
|
|
199
|
+
for (let d = 0; d < c; d++)
|
|
200
|
+
w[d] = u.charCodeAt(d);
|
|
201
|
+
const g = new Int16Array(w.buffer), A = new Float32Array(g.length);
|
|
202
|
+
for (let d = 0; d < g.length; d++)
|
|
203
|
+
A[d] = g[d] / 32768;
|
|
204
|
+
const f = n.createBuffer(1, A.length, 24e3);
|
|
205
|
+
f.getChannelData(0).set(A);
|
|
206
|
+
const y = n.createBufferSource();
|
|
207
|
+
y.buffer = f, y.connect(n.destination), m < n.currentTime && (m = n.currentTime), y.start(m), m += f.duration;
|
|
208
|
+
} catch (u) {
|
|
209
|
+
console.error("[VirtualHumanEventAdapter] Failed to decode and play audio:", u);
|
|
160
210
|
}
|
|
161
|
-
},
|
|
211
|
+
}, p = (o) => {
|
|
162
212
|
if (n)
|
|
163
|
-
switch (
|
|
213
|
+
switch (o) {
|
|
164
214
|
case "play":
|
|
165
215
|
n.state === "suspended" && n.resume();
|
|
166
216
|
break;
|
|
@@ -168,78 +218,78 @@ const F = { class: "video-wrapper" }, T = ["src", "muted"], q = { class: "overla
|
|
|
168
218
|
n.state === "running" && n.suspend();
|
|
169
219
|
break;
|
|
170
220
|
case "stop":
|
|
171
|
-
n.close(), n = null,
|
|
221
|
+
n.close(), n = null, m = 0;
|
|
172
222
|
break;
|
|
173
223
|
default:
|
|
174
|
-
console.warn(`[VirtualHumanEventAdapter] Unknown control action: ${
|
|
224
|
+
console.warn(`[VirtualHumanEventAdapter] Unknown control action: ${o}`);
|
|
175
225
|
}
|
|
176
|
-
},
|
|
177
|
-
|
|
226
|
+
}, C = () => {
|
|
227
|
+
l.value && l.value.close();
|
|
178
228
|
try {
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
},
|
|
229
|
+
const o = new URL(t.wsUrl);
|
|
230
|
+
o.searchParams.append("sessionId", t.screenClientId + "-event"), l.value = new WebSocket(o.toString()), l.value.onopen = () => {
|
|
231
|
+
k.value = !0, s("connected"), console.log(`[VirtualHumanEventAdapter] Connected to ${t.wsUrl} for session ${t.screenClientId}-event`);
|
|
232
|
+
}, l.value.onmessage = (u) => {
|
|
183
233
|
try {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
} catch (
|
|
187
|
-
console.error("[VirtualHumanEventAdapter] Failed to parse message:",
|
|
234
|
+
const c = JSON.parse(u.data);
|
|
235
|
+
x(c);
|
|
236
|
+
} catch (c) {
|
|
237
|
+
console.error("[VirtualHumanEventAdapter] Failed to parse message:", u.data, c);
|
|
188
238
|
}
|
|
189
|
-
},
|
|
190
|
-
console.error("[VirtualHumanEventAdapter] WebSocket error:",
|
|
191
|
-
},
|
|
192
|
-
|
|
239
|
+
}, l.value.onerror = (u) => {
|
|
240
|
+
console.error("[VirtualHumanEventAdapter] WebSocket error:", u), s("error", u);
|
|
241
|
+
}, l.value.onclose = () => {
|
|
242
|
+
k.value = !1, console.log("[VirtualHumanEventAdapter] WebSocket disconnected");
|
|
193
243
|
};
|
|
194
|
-
} catch (
|
|
195
|
-
console.error("[VirtualHumanEventAdapter] Failed to initialize WebSocket:",
|
|
244
|
+
} catch (o) {
|
|
245
|
+
console.error("[VirtualHumanEventAdapter] Failed to initialize WebSocket:", o), s("error", o);
|
|
196
246
|
}
|
|
197
|
-
},
|
|
198
|
-
const { type:
|
|
199
|
-
switch (
|
|
247
|
+
}, x = (o) => {
|
|
248
|
+
const { type: u, payload: c, action: w } = o;
|
|
249
|
+
switch (console.log("msgmsg", o), u) {
|
|
200
250
|
case "audio":
|
|
201
|
-
const
|
|
202
|
-
|
|
251
|
+
const g = (c == null ? void 0 : c.data) || o.data;
|
|
252
|
+
g && E(g);
|
|
203
253
|
break;
|
|
204
254
|
case "dialog_event":
|
|
205
|
-
|
|
255
|
+
o.event && s(o.event, o.params);
|
|
206
256
|
break;
|
|
207
257
|
case "control":
|
|
208
|
-
|
|
258
|
+
w && p(w);
|
|
209
259
|
break;
|
|
210
260
|
case "highlight":
|
|
211
|
-
s("highlight",
|
|
261
|
+
s("highlight", c);
|
|
212
262
|
break;
|
|
213
263
|
case "showDialog":
|
|
214
|
-
s("showDialog",
|
|
264
|
+
s("showDialog", c);
|
|
215
265
|
break;
|
|
216
266
|
case "end":
|
|
217
|
-
s("end",
|
|
267
|
+
s("end", c);
|
|
218
268
|
break;
|
|
219
269
|
case "pause":
|
|
220
|
-
s("pause",
|
|
270
|
+
s("pause", c);
|
|
221
271
|
break;
|
|
222
272
|
default:
|
|
223
|
-
console.warn(`[VirtualHumanEventAdapter] Unknown message type: ${
|
|
273
|
+
console.warn(`[VirtualHumanEventAdapter] Unknown message type: ${u}`);
|
|
224
274
|
}
|
|
225
275
|
};
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
}),
|
|
229
|
-
|
|
230
|
-
}),
|
|
231
|
-
|
|
232
|
-
}),
|
|
233
|
-
|
|
234
|
-
}), (
|
|
276
|
+
return z(() => t.screenClientId, () => {
|
|
277
|
+
t.screenClientId && t.wsUrl && C();
|
|
278
|
+
}), z(() => t.wsUrl, () => {
|
|
279
|
+
t.screenClientId && t.wsUrl && C();
|
|
280
|
+
}), W(() => {
|
|
281
|
+
t.screenClientId && t.wsUrl && C();
|
|
282
|
+
}), X(() => {
|
|
283
|
+
l.value && l.value.close(), n && n.close();
|
|
284
|
+
}), (o, u) => J(o.$slots, "default");
|
|
235
285
|
}
|
|
236
|
-
}),
|
|
237
|
-
|
|
238
|
-
},
|
|
239
|
-
install:
|
|
286
|
+
}), ne = (i) => {
|
|
287
|
+
i.component("VirtualHumanPersona", ee), i.component("VirtualHumanEventAdapter", te);
|
|
288
|
+
}, oe = {
|
|
289
|
+
install: ne
|
|
240
290
|
};
|
|
241
291
|
export {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
292
|
+
te as VirtualHumanEventAdapter,
|
|
293
|
+
ee as VirtualHumanPersona,
|
|
294
|
+
oe as default
|
|
245
295
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
(function(
|
|
1
|
+
(function(p,e){typeof exports=="object"&&typeof module<"u"?e(exports,require("vue")):typeof define=="function"&&define.amd?define(["exports","vue"],e):(p=typeof globalThis<"u"?globalThis:p||self,e(p.VirtualHumanCf={},p.Vue))})(this,function(p,e){"use strict";const z=["src","muted"],N={class:"overlay"},T={key:0,class:"status-badge paused"},R={key:1,class:"status-badge playing"},I=((c,V)=>{const n=c.__vccOpts||c;for(const[s,r]of V)n[s]=r;return n})(e.defineComponent({__name:"VirtualHumanPersona",props:{videoSrc:{type:String,required:!0},visible:{type:Boolean,default:!1},isPlaying:{type:Boolean,default:!1},muted:{type:Boolean,default:!0},isDark:{type:Boolean,default:!1},screenClientId:{type:String,required:!1},wsUrl:{type:String,required:!1}},emits:["update:isPlaying","ended","update:visible"],setup(c,{emit:V}){const n=c,s=V,r=e.ref(null),v=e.ref(null),o=e.ref(null),g=e.ref(!1),E=e.ref(1),S=e.ref(!1),h=e.ref(null),b=e.ref(0),H=e.ref(0),l=e.ref(1),d=()=>{if(o.value&&o.value.close(),!(!n.wsUrl||!n.screenClientId))try{const i=new URL(n.wsUrl);i.searchParams.append("sessionId",n.screenClientId+"-persona"),o.value=new WebSocket(i.toString()),o.value.onopen=()=>{g.value=!0,console.log(`[VirtualHumanPersona] Connected to ${n.wsUrl} for session ${n.screenClientId}-persona`)},o.value.onmessage=t=>{try{const a=JSON.parse(t.data);u(a)}catch(a){console.error("[VirtualHumanPersona] Failed to parse message:",t.data,a)}},o.value.onerror=t=>{console.error("[VirtualHumanPersona] WebSocket error:",t)},o.value.onclose=()=>{g.value=!1,console.log("[VirtualHumanPersona] WebSocket disconnected")}}catch(i){console.error("[VirtualHumanPersona] Failed to initialize WebSocket:",i)}},u=i=>{const{type:t,action:a}=i;t==="control"&&(a==="play"?(s("update:isPlaying",!0),s("update:visible",!0)):a==="pause"?s("update:isPlaying",!1):a==="stop"&&(s("update:isPlaying",!1),s("update:visible",!1),r.value&&(r.value.currentTime=0)))};e.watch(()=>n.screenClientId,()=>{n.screenClientId&&n.wsUrl&&d()}),e.watch(()=>n.wsUrl,()=>{n.screenClientId&&n.wsUrl&&d()}),e.watch(()=>n.isPlaying,i=>{r.value&&(i?r.value.play().catch(t=>console.error("Video play failed:",t)):r.value.pause())});const k=()=>{n.isPlaying||s("update:isPlaying",!0)},y=()=>{n.isPlaying&&s("update:isPlaying",!1)},P=()=>{s("update:isPlaying",!1),s("ended")},m=(i,t)=>{S.value=!0,h.value=i,l.value=E.value;const a="clientX"in t?t.clientX:t.touches[0].clientX,C="clientY"in t?t.clientY:t.touches[0].clientY;b.value=a,H.value=C,document.addEventListener("mousemove",w),document.addEventListener("mouseup",f),document.addEventListener("touchmove",w),document.addEventListener("touchend",f)},w=i=>{if(!S.value||!v.value)return;const t="clientX"in i?i.clientX:i.touches[0].clientX,a="clientY"in i?i.clientY:i.touches[0].clientY,C=t-b.value,U=a-H.value,x=v.value.getBoundingClientRect(),W=.5,X=2;let B=l.value;const A=Math.min(x.width,x.height);h.value==="bottom-right"?B=l.value+(C+U)/A:h.value==="bottom-left"?B=l.value+(-C+U)/A:h.value==="top-right"?B=l.value+(C-U)/A:h.value==="top-left"&&(B=l.value+(-C-U)/A),E.value=Math.max(W,Math.min(X,B))},f=()=>{S.value=!1,h.value=null,document.removeEventListener("mousemove",w),document.removeEventListener("mouseup",f),document.removeEventListener("touchmove",w),document.removeEventListener("touchend",f)};return e.onMounted(()=>{n.isPlaying&&r.value&&r.value.play().catch(i=>console.error("Video play failed:",i)),n.screenClientId&&n.wsUrl&&d()}),e.onUnmounted(()=>{o.value&&o.value.close()}),(i,t)=>(e.openBlock(),e.createBlock(e.Transition,{name:"fade"},{default:e.withCtx(()=>[c.visible?(e.openBlock(),e.createElementBlock("div",{key:0,class:e.normalizeClass(["virtual-human-container",{"is-dark":c.isDark}])},[e.createElementVNode("div",{class:"video-wrapper",ref_key:"wrapperRef",ref:v},[e.createElementVNode("video",{ref_key:"videoRef",ref:r,src:c.videoSrc,class:"persona-video",muted:c.muted,playsinline:"",loop:"",onPlay:k,onPause:y,onEnded:P,style:e.normalizeStyle({transform:`scale(${E.value})`})},null,44,z),c.visible?(e.openBlock(),e.createElementBlock("div",{key:0,class:"resize-handle top-left",onMousedown:t[0]||(t[0]=a=>m("top-left",a)),onTouchstart:t[1]||(t[1]=a=>m("top-left",a))},null,32)):e.createCommentVNode("",!0),c.visible?(e.openBlock(),e.createElementBlock("div",{key:1,class:"resize-handle top-right",onMousedown:t[2]||(t[2]=a=>m("top-right",a)),onTouchstart:t[3]||(t[3]=a=>m("top-right",a))},null,32)):e.createCommentVNode("",!0),c.visible?(e.openBlock(),e.createElementBlock("div",{key:2,class:"resize-handle bottom-left",onMousedown:t[4]||(t[4]=a=>m("bottom-left",a)),onTouchstart:t[5]||(t[5]=a=>m("bottom-left",a))},null,32)):e.createCommentVNode("",!0),c.visible?(e.openBlock(),e.createElementBlock("div",{key:3,class:"resize-handle bottom-right",onMousedown:t[6]||(t[6]=a=>m("bottom-right",a)),onTouchstart:t[7]||(t[7]=a=>m("bottom-right",a))},null,32)):e.createCommentVNode("",!0),e.createElementVNode("div",N,[c.isPlaying?(e.openBlock(),e.createElementBlock("div",R,[...t[9]||(t[9]=[e.createElementVNode("span",{class:"dot animate-pulse"},null,-1),e.createTextVNode(" 播放中 ",-1)])])):(e.openBlock(),e.createElementBlock("div",T,[...t[8]||(t[8]=[e.createElementVNode("span",{class:"dot"},null,-1),e.createTextVNode(" 暂停中 ",-1)])]))])],512)],2)):e.createCommentVNode("",!0)]),_:1}))}}),[["__scopeId","data-v-2fb9caca"]]),M=e.defineComponent({__name:"VirtualHumanEventAdapter",props:{screenClientId:{type:String,required:!0},wsUrl:{type:String,required:!0}},emits:["highlight","showDialog","end","pause","connected","error"],setup(c,{emit:V}){const n=c,s=V,r=e.ref(null),v=e.ref(!1);let o=null,g=0;const E=()=>{o||(o=new(window.AudioContext||window.webkitAudioContext)({sampleRate:24e3})),o.state==="suspended"&&o.resume()},S=l=>{if(E(),!!o)try{const d=window.atob(l),u=d.length,k=new Uint8Array(u);for(let f=0;f<u;f++)k[f]=d.charCodeAt(f);const y=new Int16Array(k.buffer),P=new Float32Array(y.length);for(let f=0;f<y.length;f++)P[f]=y[f]/32768;const m=o.createBuffer(1,P.length,24e3);m.getChannelData(0).set(P);const w=o.createBufferSource();w.buffer=m,w.connect(o.destination),g<o.currentTime&&(g=o.currentTime),w.start(g),g+=m.duration}catch(d){console.error("[VirtualHumanEventAdapter] Failed to decode and play audio:",d)}},h=l=>{if(o)switch(l){case"play":o.state==="suspended"&&o.resume();break;case"pause":o.state==="running"&&o.suspend();break;case"stop":o.close(),o=null,g=0;break;default:console.warn(`[VirtualHumanEventAdapter] Unknown control action: ${l}`)}},b=()=>{r.value&&r.value.close();try{const l=new URL(n.wsUrl);l.searchParams.append("sessionId",n.screenClientId+"-event"),r.value=new WebSocket(l.toString()),r.value.onopen=()=>{v.value=!0,s("connected"),console.log(`[VirtualHumanEventAdapter] Connected to ${n.wsUrl} for session ${n.screenClientId}-event`)},r.value.onmessage=d=>{try{const u=JSON.parse(d.data);H(u)}catch(u){console.error("[VirtualHumanEventAdapter] Failed to parse message:",d.data,u)}},r.value.onerror=d=>{console.error("[VirtualHumanEventAdapter] WebSocket error:",d),s("error",d)},r.value.onclose=()=>{v.value=!1,console.log("[VirtualHumanEventAdapter] WebSocket disconnected")}}catch(l){console.error("[VirtualHumanEventAdapter] Failed to initialize WebSocket:",l),s("error",l)}},H=l=>{const{type:d,payload:u,action:k}=l;switch(console.log("msgmsg",l),d){case"audio":const y=(u==null?void 0:u.data)||l.data;y&&S(y);break;case"dialog_event":l.event&&s(l.event,l.params);break;case"control":k&&h(k);break;case"highlight":s("highlight",u);break;case"showDialog":s("showDialog",u);break;case"end":s("end",u);break;case"pause":s("pause",u);break;default:console.warn(`[VirtualHumanEventAdapter] Unknown message type: ${d}`)}};return e.watch(()=>n.screenClientId,()=>{n.screenClientId&&n.wsUrl&&b()}),e.watch(()=>n.wsUrl,()=>{n.screenClientId&&n.wsUrl&&b()}),e.onMounted(()=>{n.screenClientId&&n.wsUrl&&b()}),e.onUnmounted(()=>{r.value&&r.value.close(),o&&o.close()}),(l,d)=>e.renderSlot(l.$slots,"default")}}),L={install:c=>{c.component("VirtualHumanPersona",I),c.component("VirtualHumanEventAdapter",M)}};p.VirtualHumanEventAdapter=M,p.VirtualHumanPersona=I,p.default=L,Object.defineProperties(p,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
|
package/package.json
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
|
7
7
|
|
|
8
8
|
const props = defineProps({
|
|
9
|
+
// 屏幕客户端ID
|
|
9
10
|
screenClientId: {
|
|
10
11
|
type: String,
|
|
11
12
|
required: true,
|
|
12
13
|
},
|
|
14
|
+
// WebSocket URL
|
|
13
15
|
wsUrl: {
|
|
14
16
|
type: String,
|
|
15
17
|
required: true,
|
|
@@ -138,6 +140,7 @@ const connectWebSocket = () => {
|
|
|
138
140
|
|
|
139
141
|
const handleMessage = (msg: any) => {
|
|
140
142
|
const { type, payload, action } = msg;
|
|
143
|
+
console.log("msgmsg",msg)
|
|
141
144
|
switch (type) {
|
|
142
145
|
case 'audio':
|
|
143
146
|
const base64Data = payload?.data || msg.data;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
class="virtual-human-container"
|
|
6
6
|
:class="{ 'is-dark': isDark }"
|
|
7
7
|
>
|
|
8
|
-
<div class="video-wrapper">
|
|
8
|
+
<div class="video-wrapper" ref="wrapperRef">
|
|
9
9
|
<video
|
|
10
10
|
ref="videoRef"
|
|
11
11
|
:src="videoSrc"
|
|
@@ -16,8 +16,35 @@
|
|
|
16
16
|
@play="handlePlay"
|
|
17
17
|
@pause="handlePause"
|
|
18
18
|
@ended="handleEnded"
|
|
19
|
+
:style="{ transform: `scale(${scale})` }"
|
|
19
20
|
></video>
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
<!-- 缩放控制点 - 四个角 -->
|
|
23
|
+
<div
|
|
24
|
+
v-if="visible"
|
|
25
|
+
class="resize-handle top-left"
|
|
26
|
+
@mousedown="startResize('top-left', $event)"
|
|
27
|
+
@touchstart="startResize('top-left', $event)"
|
|
28
|
+
></div>
|
|
29
|
+
<div
|
|
30
|
+
v-if="visible"
|
|
31
|
+
class="resize-handle top-right"
|
|
32
|
+
@mousedown="startResize('top-right', $event)"
|
|
33
|
+
@touchstart="startResize('top-right', $event)"
|
|
34
|
+
></div>
|
|
35
|
+
<div
|
|
36
|
+
v-if="visible"
|
|
37
|
+
class="resize-handle bottom-left"
|
|
38
|
+
@mousedown="startResize('bottom-left', $event)"
|
|
39
|
+
@touchstart="startResize('bottom-left', $event)"
|
|
40
|
+
></div>
|
|
41
|
+
<div
|
|
42
|
+
v-if="visible"
|
|
43
|
+
class="resize-handle bottom-right"
|
|
44
|
+
@mousedown="startResize('bottom-right', $event)"
|
|
45
|
+
@touchstart="startResize('bottom-right', $event)"
|
|
46
|
+
></div>
|
|
47
|
+
|
|
21
48
|
<!-- UI Overlay for controls or status -->
|
|
22
49
|
<div class="overlay">
|
|
23
50
|
<div v-if="!isPlaying" class="status-badge paused">
|
|
@@ -38,30 +65,37 @@
|
|
|
38
65
|
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
|
39
66
|
|
|
40
67
|
const props = defineProps({
|
|
68
|
+
// 视频源URL
|
|
41
69
|
videoSrc: {
|
|
42
70
|
type: String,
|
|
43
71
|
required: true,
|
|
44
72
|
},
|
|
73
|
+
// 是否可见
|
|
45
74
|
visible: {
|
|
46
75
|
type: Boolean,
|
|
47
|
-
default:
|
|
76
|
+
default: false,
|
|
48
77
|
},
|
|
78
|
+
// 是否自动播放
|
|
49
79
|
isPlaying: {
|
|
50
80
|
type: Boolean,
|
|
51
81
|
default: false,
|
|
52
82
|
},
|
|
83
|
+
// 是否静音
|
|
53
84
|
muted: {
|
|
54
85
|
type: Boolean,
|
|
55
86
|
default: true, // Auto-play policies usually require muted
|
|
56
87
|
},
|
|
88
|
+
// 是否暗黑模式
|
|
57
89
|
isDark: {
|
|
58
90
|
type: Boolean,
|
|
59
91
|
default: false,
|
|
60
92
|
},
|
|
93
|
+
// 屏幕客户端ID
|
|
61
94
|
screenClientId: {
|
|
62
95
|
type: String,
|
|
63
96
|
required: false,
|
|
64
97
|
},
|
|
98
|
+
// WebSocket URL
|
|
65
99
|
wsUrl: {
|
|
66
100
|
type: String,
|
|
67
101
|
required: false,
|
|
@@ -71,10 +105,19 @@ const props = defineProps({
|
|
|
71
105
|
const emit = defineEmits(['update:isPlaying', 'ended', 'update:visible']);
|
|
72
106
|
|
|
73
107
|
const videoRef = ref<HTMLVideoElement | null>(null);
|
|
108
|
+
const wrapperRef = ref<HTMLDivElement | null>(null);
|
|
74
109
|
|
|
75
110
|
const ws = ref<WebSocket | null>(null);
|
|
76
111
|
const isConnected = ref(false);
|
|
77
112
|
|
|
113
|
+
// 缩放相关状态
|
|
114
|
+
const scale = ref(1);
|
|
115
|
+
const isResizing = ref(false);
|
|
116
|
+
const resizeHandle = ref<string | null>(null);
|
|
117
|
+
const startX = ref(0);
|
|
118
|
+
const startY = ref(0);
|
|
119
|
+
const startScale = ref(1);
|
|
120
|
+
|
|
78
121
|
const connectWebSocket = () => {
|
|
79
122
|
if (ws.value) {
|
|
80
123
|
ws.value.close();
|
|
@@ -122,7 +165,7 @@ const handleMessage = (msg: any) => {
|
|
|
122
165
|
emit('update:visible', true);
|
|
123
166
|
} else if (action === 'pause') {
|
|
124
167
|
emit('update:isPlaying', false);
|
|
125
|
-
|
|
168
|
+
// 暂停时不隐藏视频
|
|
126
169
|
} else if (action === 'stop') {
|
|
127
170
|
emit('update:isPlaying', false);
|
|
128
171
|
emit('update:visible', false);
|
|
@@ -172,6 +215,62 @@ const handleEnded = () => {
|
|
|
172
215
|
emit('ended');
|
|
173
216
|
};
|
|
174
217
|
|
|
218
|
+
// 缩放功能
|
|
219
|
+
const startResize = (handle: string, event: MouseEvent | TouchEvent) => {
|
|
220
|
+
isResizing.value = true;
|
|
221
|
+
resizeHandle.value = handle;
|
|
222
|
+
startScale.value = scale.value;
|
|
223
|
+
|
|
224
|
+
const clientX = 'clientX' in event ? event.clientX : event.touches[0].clientX;
|
|
225
|
+
const clientY = 'clientY' in event ? event.clientY : event.touches[0].clientY;
|
|
226
|
+
startX.value = clientX;
|
|
227
|
+
startY.value = clientY;
|
|
228
|
+
|
|
229
|
+
document.addEventListener('mousemove', handleResize);
|
|
230
|
+
document.addEventListener('mouseup', stopResize);
|
|
231
|
+
document.addEventListener('touchmove', handleResize);
|
|
232
|
+
document.addEventListener('touchend', stopResize);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const handleResize = (event: MouseEvent | TouchEvent) => {
|
|
236
|
+
if (!isResizing.value || !wrapperRef.value) return;
|
|
237
|
+
|
|
238
|
+
const clientX = 'clientX' in event ? event.clientX : event.touches[0].clientX;
|
|
239
|
+
const clientY = 'clientY' in event ? event.clientY : event.touches[0].clientY;
|
|
240
|
+
|
|
241
|
+
const deltaX = clientX - startX.value;
|
|
242
|
+
const deltaY = clientY - startY.value;
|
|
243
|
+
|
|
244
|
+
const rect = wrapperRef.value.getBoundingClientRect();
|
|
245
|
+
const minScale = 0.5;
|
|
246
|
+
const maxScale = 2;
|
|
247
|
+
|
|
248
|
+
// 根据拖动的角计算缩放比例
|
|
249
|
+
let newScale = startScale.value;
|
|
250
|
+
const baseSize = Math.min(rect.width, rect.height);
|
|
251
|
+
|
|
252
|
+
if (resizeHandle.value === 'bottom-right') {
|
|
253
|
+
newScale = startScale.value + (deltaX + deltaY) / baseSize;
|
|
254
|
+
} else if (resizeHandle.value === 'bottom-left') {
|
|
255
|
+
newScale = startScale.value + (-deltaX + deltaY) / baseSize;
|
|
256
|
+
} else if (resizeHandle.value === 'top-right') {
|
|
257
|
+
newScale = startScale.value + (deltaX - deltaY) / baseSize;
|
|
258
|
+
} else if (resizeHandle.value === 'top-left') {
|
|
259
|
+
newScale = startScale.value + (-deltaX - deltaY) / baseSize;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
scale.value = Math.max(minScale, Math.min(maxScale, newScale));
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const stopResize = () => {
|
|
266
|
+
isResizing.value = false;
|
|
267
|
+
resizeHandle.value = null;
|
|
268
|
+
document.removeEventListener('mousemove', handleResize);
|
|
269
|
+
document.removeEventListener('mouseup', stopResize);
|
|
270
|
+
document.removeEventListener('touchmove', handleResize);
|
|
271
|
+
document.removeEventListener('touchend', stopResize);
|
|
272
|
+
};
|
|
273
|
+
|
|
175
274
|
onMounted(() => {
|
|
176
275
|
if (props.isPlaying && videoRef.value) {
|
|
177
276
|
videoRef.value.play().catch(e => console.error('Video play failed:', e));
|
|
@@ -221,12 +320,56 @@ onUnmounted(() => {
|
|
|
221
320
|
width: 100%;
|
|
222
321
|
aspect-ratio: 9 / 16; /* Portrait ratio for digital human */
|
|
223
322
|
background: #000;
|
|
323
|
+
overflow: visible;
|
|
224
324
|
}
|
|
225
325
|
|
|
226
326
|
.persona-video {
|
|
227
327
|
width: 100%;
|
|
228
328
|
height: 100%;
|
|
229
329
|
object-fit: cover;
|
|
330
|
+
transform-origin: center;
|
|
331
|
+
transition: transform 0.1s ease-out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.resize-handle {
|
|
335
|
+
position: absolute;
|
|
336
|
+
width: 20px;
|
|
337
|
+
height: 20px;
|
|
338
|
+
background: rgba(0, 0, 0, 0.5);
|
|
339
|
+
border: 2px solid rgba(255, 255, 255, 0.8);
|
|
340
|
+
border-radius: 4px;
|
|
341
|
+
cursor: pointer;
|
|
342
|
+
z-index: 20;
|
|
343
|
+
transition: background 0.2s, border-color 0.2s;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.resize-handle:hover {
|
|
347
|
+
background: rgba(0, 0, 0, 0.7);
|
|
348
|
+
border-color: rgba(255, 255, 255, 1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.resize-handle.top-left {
|
|
352
|
+
top: -10px;
|
|
353
|
+
left: -10px;
|
|
354
|
+
cursor: nwse-resize;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.resize-handle.top-right {
|
|
358
|
+
top: -10px;
|
|
359
|
+
right: -10px;
|
|
360
|
+
cursor: nesw-resize;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.resize-handle.bottom-left {
|
|
364
|
+
bottom: -10px;
|
|
365
|
+
left: -10px;
|
|
366
|
+
cursor: nesw-resize;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.resize-handle.bottom-right {
|
|
370
|
+
bottom: -10px;
|
|
371
|
+
right: -10px;
|
|
372
|
+
cursor: nwse-resize;
|
|
230
373
|
}
|
|
231
374
|
|
|
232
375
|
.overlay {
|