chenbhao commited on
Commit
e1bb50d
·
1 Parent(s): ed2241e

feat: /friend

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +5 -5
  2. adapters/common/session-store.js +7 -0
  3. adapters/wechat/protocol.js +10 -0
  4. friend/frontend/index.html +102 -0
  5. friend/frontend/package-lock.json +2372 -0
  6. friend/frontend/package.json +30 -0
  7. friend/frontend/public/akimbo.vrma +3 -0
  8. friend/frontend/public/angry.fbx +3 -0
  9. friend/frontend/public/angryPump.fbx +3 -0
  10. friend/frontend/public/excited.fbx +3 -0
  11. friend/frontend/public/greeting.fbx +3 -0
  12. friend/frontend/public/happy.fbx +3 -0
  13. friend/frontend/public/idle_loop.vrma +3 -0
  14. friend/frontend/public/jile.vmd +3 -0
  15. friend/frontend/public/love.vmd +3 -0
  16. friend/frontend/public/playFingers.vrma +3 -0
  17. friend/frontend/public/point.fbx +3 -0
  18. friend/frontend/public/salute.fbx +3 -0
  19. friend/frontend/public/scratchHead.vrma +3 -0
  20. friend/frontend/public/shy.fbx +3 -0
  21. friend/frontend/public/stretch.vrma +3 -0
  22. friend/frontend/src-tauri/Cargo.lock +0 -0
  23. friend/frontend/src-tauri/Cargo.toml +23 -0
  24. friend/frontend/src-tauri/build.rs +3 -0
  25. friend/frontend/src-tauri/capabilities/default.json +9 -0
  26. friend/frontend/src-tauri/icons/128x128.png +3 -0
  27. friend/frontend/src-tauri/icons/256x256.png +3 -0
  28. friend/frontend/src-tauri/icons/32x32.png +3 -0
  29. friend/frontend/src-tauri/src/lib.rs +6 -0
  30. friend/frontend/src-tauri/src/main.rs +6 -0
  31. friend/frontend/src-tauri/tauri.conf.json +40 -0
  32. friend/frontend/src/App.tsx +540 -0
  33. friend/frontend/src/api.ts +51 -0
  34. friend/frontend/src/assets/lip-sync-profile.json +1 -0
  35. friend/frontend/src/components/ChatInput.tsx +672 -0
  36. friend/frontend/src/components/HistoryPanel.tsx +177 -0
  37. friend/frontend/src/components/MoodIndicator.tsx +372 -0
  38. friend/frontend/src/components/ResizeHandles.tsx +7 -0
  39. friend/frontend/src/components/SettingsPanel.tsx +1020 -0
  40. friend/frontend/src/components/TextBubble.tsx +571 -0
  41. friend/frontend/src/components/VRMScene.tsx +870 -0
  42. friend/frontend/src/emote.ts +206 -0
  43. friend/frontend/src/hooks/usePassThrough.ts +12 -0
  44. friend/frontend/src/lip-sync.ts +187 -0
  45. friend/frontend/src/main.tsx +9 -0
  46. friend/frontend/src/mixamo-loader.ts +141 -0
  47. friend/frontend/src/mmd-parser.d.ts +39 -0
  48. friend/frontend/src/motion-controller.ts +420 -0
  49. friend/frontend/src/three-helpers.ts +33 -0
  50. friend/frontend/src/vmd-loader.ts +663 -0
.gitignore CHANGED
@@ -8,11 +8,11 @@ VersperClaw
8
  # Prebuilt native binaries (tracked in claude-code/ reference)
9
  vendor/
10
 
11
- # Desktop (Tauri) build artifacts
12
- desktop/src-tauri/target/
13
- desktop/src-tauri/binaries/
14
- desktop/src-tauri/gen/
15
- desktop/pnpm-lock.yaml
16
 
17
  # Python cache
18
  __pycache__/
 
8
  # Prebuilt native binaries (tracked in claude-code/ reference)
9
  vendor/
10
 
11
+ # Friend VRM desktop (Tauri) build artifacts
12
+ friend/frontend/node_modules/
13
+ friend/frontend/dist/
14
+ friend/frontend/src-tauri/target/
15
+ friend/frontend/src-tauri/gen/
16
 
17
  # Python cache
18
  __pycache__/
adapters/common/session-store.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ // Stub for common SessionStore adapter
2
+ export class SessionStore {
3
+ static getInstance() { return null; }
4
+ async get() { return null; }
5
+ async set() {}
6
+ async delete() {}
7
+ }
adapters/wechat/protocol.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ // Stub for WeChat adapter protocol
2
+ export const WECHAT_DEFAULT_BASE_URL = 'https://api.weixin.qq.com';
3
+
4
+ export async function startWechatLoginWithQr() {
5
+ return { ok: false };
6
+ }
7
+
8
+ export async function pollWechatLoginWithQr() {
9
+ return { ok: false };
10
+ }
friend/frontend/index.html ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Claw Sama</title>
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ html,
16
+ body,
17
+ #root {
18
+ width: 100%;
19
+ height: 100%;
20
+ overflow: hidden;
21
+ background: transparent;
22
+ }
23
+
24
+ @keyframes spin {
25
+ from {
26
+ transform: rotate(0deg);
27
+ }
28
+
29
+ to {
30
+ transform: rotate(360deg);
31
+ }
32
+ }
33
+
34
+ @keyframes claw-input-slide-up {
35
+ 0% { opacity: 0; transform: translateY(20px) scale(0.95); }
36
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
37
+ }
38
+
39
+ @keyframes claw-input-slide-down {
40
+ 0% { opacity: 1; transform: translateY(0) scale(1); }
41
+ 100% { opacity: 0; transform: translateY(20px) scale(0.95); }
42
+ }
43
+
44
+ @keyframes claw-pulse {
45
+ 0%, 100% { opacity: 1; }
46
+ 50% { opacity: 0.4; }
47
+ }
48
+
49
+ @keyframes claw-pop-in {
50
+ 0% { opacity: 0; transform: scale(0) translateY(0.5em); }
51
+ 60% { opacity: 1; transform: scale(1.15) translateY(-0.05em); }
52
+ 100% { opacity: 1; transform: scale(1) translateY(0); }
53
+ }
54
+
55
+ @keyframes mood-bubble-float {
56
+ 0% { opacity: 1; transform: translateX(-50%) translateY(0) scale(0.8); }
57
+ 15% { opacity: 1; transform: translateX(-50%) translateY(-8px) scale(1.1); }
58
+ 30% { opacity: 1; transform: translateX(-50%) translateY(-16px) scale(1); }
59
+ 70% { opacity: 0.9; }
60
+ 100% { opacity: 0; transform: translateX(-50%) translateY(-50px) scale(0.9); }
61
+ }
62
+
63
+ /* Touch ripple */
64
+ .touch-ripple {
65
+ position: fixed;
66
+ pointer-events: none;
67
+ width: 40px;
68
+ height: 40px;
69
+ margin-left: -20px;
70
+ margin-top: -20px;
71
+ border-radius: 50%;
72
+ border: 2px solid rgba(255, 255, 255, 0.5);
73
+ animation: ripple-expand 0.5s ease-out forwards;
74
+ }
75
+ .touch-ripple.confirmed {
76
+ width: 60px;
77
+ height: 60px;
78
+ margin-left: -30px;
79
+ margin-top: -30px;
80
+ border: 2.5px solid rgba(255, 180, 220, 0.8);
81
+ box-shadow: 0 0 12px rgba(255, 180, 220, 0.4);
82
+ animation: ripple-expand-confirmed 0.7s ease-out forwards;
83
+ }
84
+
85
+ @keyframes ripple-expand {
86
+ 0% { transform: scale(0); opacity: 0.7; }
87
+ 100% { transform: scale(1.8); opacity: 0; }
88
+ }
89
+ @keyframes ripple-expand-confirmed {
90
+ 0% { transform: scale(0); opacity: 0.9; }
91
+ 50% { transform: scale(1.2); opacity: 0.6; }
92
+ 100% { transform: scale(2.2); opacity: 0; }
93
+ }
94
+ </style>
95
+ </head>
96
+
97
+ <body>
98
+ <div id="root"></div>
99
+ <script type="module" src="/src/main.tsx"></script>
100
+ </body>
101
+
102
+ </html>
friend/frontend/package-lock.json ADDED
@@ -0,0 +1,2372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "friend-vrm-frontend",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "friend-vrm-frontend",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "@pixiv/three-vrm": "^3.4.0",
12
+ "@pixiv/three-vrm-animation": "^3.5.0",
13
+ "@pixiv/three-vrm-core": "^3.5.1",
14
+ "lucide-react": "^0.577.0",
15
+ "mmd-parser": "^1.0.4",
16
+ "react": "^18.3.1",
17
+ "react-dom": "^18.3.1",
18
+ "three": "^0.175.0",
19
+ "wlipsync": "^1.3.0"
20
+ },
21
+ "devDependencies": {
22
+ "@tauri-apps/cli": "^2.11.3",
23
+ "@types/react": "^18.3.23",
24
+ "@types/react-dom": "^18.3.7",
25
+ "@types/three": "^0.175.0",
26
+ "@vitejs/plugin-react": "^4.5.0",
27
+ "typescript": "^5.8.3",
28
+ "vite": "^6.3.2"
29
+ }
30
+ },
31
+ "node_modules/@babel/code-frame": {
32
+ "version": "7.29.7",
33
+ "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz",
34
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
35
+ "dev": true,
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "@babel/helper-validator-identifier": "^7.29.7",
39
+ "js-tokens": "^4.0.0",
40
+ "picocolors": "^1.1.1"
41
+ },
42
+ "engines": {
43
+ "node": ">=6.9.0"
44
+ }
45
+ },
46
+ "node_modules/@babel/compat-data": {
47
+ "version": "7.29.7",
48
+ "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz",
49
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
50
+ "dev": true,
51
+ "license": "MIT",
52
+ "engines": {
53
+ "node": ">=6.9.0"
54
+ }
55
+ },
56
+ "node_modules/@babel/core": {
57
+ "version": "7.29.7",
58
+ "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz",
59
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
60
+ "dev": true,
61
+ "license": "MIT",
62
+ "dependencies": {
63
+ "@babel/code-frame": "^7.29.7",
64
+ "@babel/generator": "^7.29.7",
65
+ "@babel/helper-compilation-targets": "^7.29.7",
66
+ "@babel/helper-module-transforms": "^7.29.7",
67
+ "@babel/helpers": "^7.29.7",
68
+ "@babel/parser": "^7.29.7",
69
+ "@babel/template": "^7.29.7",
70
+ "@babel/traverse": "^7.29.7",
71
+ "@babel/types": "^7.29.7",
72
+ "@jridgewell/remapping": "^2.3.5",
73
+ "convert-source-map": "^2.0.0",
74
+ "debug": "^4.1.0",
75
+ "gensync": "^1.0.0-beta.2",
76
+ "json5": "^2.2.3",
77
+ "semver": "^6.3.1"
78
+ },
79
+ "engines": {
80
+ "node": ">=6.9.0"
81
+ },
82
+ "funding": {
83
+ "type": "opencollective",
84
+ "url": "https://opencollective.com/babel"
85
+ }
86
+ },
87
+ "node_modules/@babel/generator": {
88
+ "version": "7.29.7",
89
+ "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.7.tgz",
90
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
91
+ "dev": true,
92
+ "license": "MIT",
93
+ "dependencies": {
94
+ "@babel/parser": "^7.29.7",
95
+ "@babel/types": "^7.29.7",
96
+ "@jridgewell/gen-mapping": "^0.3.12",
97
+ "@jridgewell/trace-mapping": "^0.3.28",
98
+ "jsesc": "^3.0.2"
99
+ },
100
+ "engines": {
101
+ "node": ">=6.9.0"
102
+ }
103
+ },
104
+ "node_modules/@babel/helper-compilation-targets": {
105
+ "version": "7.29.7",
106
+ "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
107
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
108
+ "dev": true,
109
+ "license": "MIT",
110
+ "dependencies": {
111
+ "@babel/compat-data": "^7.29.7",
112
+ "@babel/helper-validator-option": "^7.29.7",
113
+ "browserslist": "^4.24.0",
114
+ "lru-cache": "^5.1.1",
115
+ "semver": "^6.3.1"
116
+ },
117
+ "engines": {
118
+ "node": ">=6.9.0"
119
+ }
120
+ },
121
+ "node_modules/@babel/helper-globals": {
122
+ "version": "7.29.7",
123
+ "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
124
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
125
+ "dev": true,
126
+ "license": "MIT",
127
+ "engines": {
128
+ "node": ">=6.9.0"
129
+ }
130
+ },
131
+ "node_modules/@babel/helper-module-imports": {
132
+ "version": "7.29.7",
133
+ "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
134
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
135
+ "dev": true,
136
+ "license": "MIT",
137
+ "dependencies": {
138
+ "@babel/traverse": "^7.29.7",
139
+ "@babel/types": "^7.29.7"
140
+ },
141
+ "engines": {
142
+ "node": ">=6.9.0"
143
+ }
144
+ },
145
+ "node_modules/@babel/helper-module-transforms": {
146
+ "version": "7.29.7",
147
+ "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
148
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
149
+ "dev": true,
150
+ "license": "MIT",
151
+ "dependencies": {
152
+ "@babel/helper-module-imports": "^7.29.7",
153
+ "@babel/helper-validator-identifier": "^7.29.7",
154
+ "@babel/traverse": "^7.29.7"
155
+ },
156
+ "engines": {
157
+ "node": ">=6.9.0"
158
+ },
159
+ "peerDependencies": {
160
+ "@babel/core": "^7.0.0"
161
+ }
162
+ },
163
+ "node_modules/@babel/helper-plugin-utils": {
164
+ "version": "7.29.7",
165
+ "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
166
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
167
+ "dev": true,
168
+ "license": "MIT",
169
+ "engines": {
170
+ "node": ">=6.9.0"
171
+ }
172
+ },
173
+ "node_modules/@babel/helper-string-parser": {
174
+ "version": "7.29.7",
175
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
176
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
177
+ "dev": true,
178
+ "license": "MIT",
179
+ "engines": {
180
+ "node": ">=6.9.0"
181
+ }
182
+ },
183
+ "node_modules/@babel/helper-validator-identifier": {
184
+ "version": "7.29.7",
185
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
186
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
187
+ "dev": true,
188
+ "license": "MIT",
189
+ "engines": {
190
+ "node": ">=6.9.0"
191
+ }
192
+ },
193
+ "node_modules/@babel/helper-validator-option": {
194
+ "version": "7.29.7",
195
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
196
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
197
+ "dev": true,
198
+ "license": "MIT",
199
+ "engines": {
200
+ "node": ">=6.9.0"
201
+ }
202
+ },
203
+ "node_modules/@babel/helpers": {
204
+ "version": "7.29.7",
205
+ "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.7.tgz",
206
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
207
+ "dev": true,
208
+ "license": "MIT",
209
+ "dependencies": {
210
+ "@babel/template": "^7.29.7",
211
+ "@babel/types": "^7.29.7"
212
+ },
213
+ "engines": {
214
+ "node": ">=6.9.0"
215
+ }
216
+ },
217
+ "node_modules/@babel/parser": {
218
+ "version": "7.29.7",
219
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz",
220
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
221
+ "dev": true,
222
+ "license": "MIT",
223
+ "dependencies": {
224
+ "@babel/types": "^7.29.7"
225
+ },
226
+ "bin": {
227
+ "parser": "bin/babel-parser.js"
228
+ },
229
+ "engines": {
230
+ "node": ">=6.0.0"
231
+ }
232
+ },
233
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
234
+ "version": "7.29.7",
235
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
236
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
237
+ "dev": true,
238
+ "license": "MIT",
239
+ "dependencies": {
240
+ "@babel/helper-plugin-utils": "^7.29.7"
241
+ },
242
+ "engines": {
243
+ "node": ">=6.9.0"
244
+ },
245
+ "peerDependencies": {
246
+ "@babel/core": "^7.0.0-0"
247
+ }
248
+ },
249
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
250
+ "version": "7.29.7",
251
+ "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
252
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
253
+ "dev": true,
254
+ "license": "MIT",
255
+ "dependencies": {
256
+ "@babel/helper-plugin-utils": "^7.29.7"
257
+ },
258
+ "engines": {
259
+ "node": ">=6.9.0"
260
+ },
261
+ "peerDependencies": {
262
+ "@babel/core": "^7.0.0-0"
263
+ }
264
+ },
265
+ "node_modules/@babel/template": {
266
+ "version": "7.29.7",
267
+ "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
268
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
269
+ "dev": true,
270
+ "license": "MIT",
271
+ "dependencies": {
272
+ "@babel/code-frame": "^7.29.7",
273
+ "@babel/parser": "^7.29.7",
274
+ "@babel/types": "^7.29.7"
275
+ },
276
+ "engines": {
277
+ "node": ">=6.9.0"
278
+ }
279
+ },
280
+ "node_modules/@babel/traverse": {
281
+ "version": "7.29.7",
282
+ "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.7.tgz",
283
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
284
+ "dev": true,
285
+ "license": "MIT",
286
+ "dependencies": {
287
+ "@babel/code-frame": "^7.29.7",
288
+ "@babel/generator": "^7.29.7",
289
+ "@babel/helper-globals": "^7.29.7",
290
+ "@babel/parser": "^7.29.7",
291
+ "@babel/template": "^7.29.7",
292
+ "@babel/types": "^7.29.7",
293
+ "debug": "^4.3.1"
294
+ },
295
+ "engines": {
296
+ "node": ">=6.9.0"
297
+ }
298
+ },
299
+ "node_modules/@babel/types": {
300
+ "version": "7.29.7",
301
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz",
302
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
303
+ "dev": true,
304
+ "license": "MIT",
305
+ "dependencies": {
306
+ "@babel/helper-string-parser": "^7.29.7",
307
+ "@babel/helper-validator-identifier": "^7.29.7"
308
+ },
309
+ "engines": {
310
+ "node": ">=6.9.0"
311
+ }
312
+ },
313
+ "node_modules/@esbuild/aix-ppc64": {
314
+ "version": "0.25.12",
315
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
316
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
317
+ "cpu": [
318
+ "ppc64"
319
+ ],
320
+ "dev": true,
321
+ "license": "MIT",
322
+ "optional": true,
323
+ "os": [
324
+ "aix"
325
+ ],
326
+ "engines": {
327
+ "node": ">=18"
328
+ }
329
+ },
330
+ "node_modules/@esbuild/android-arm": {
331
+ "version": "0.25.12",
332
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
333
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
334
+ "cpu": [
335
+ "arm"
336
+ ],
337
+ "dev": true,
338
+ "license": "MIT",
339
+ "optional": true,
340
+ "os": [
341
+ "android"
342
+ ],
343
+ "engines": {
344
+ "node": ">=18"
345
+ }
346
+ },
347
+ "node_modules/@esbuild/android-arm64": {
348
+ "version": "0.25.12",
349
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
350
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
351
+ "cpu": [
352
+ "arm64"
353
+ ],
354
+ "dev": true,
355
+ "license": "MIT",
356
+ "optional": true,
357
+ "os": [
358
+ "android"
359
+ ],
360
+ "engines": {
361
+ "node": ">=18"
362
+ }
363
+ },
364
+ "node_modules/@esbuild/android-x64": {
365
+ "version": "0.25.12",
366
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
367
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
368
+ "cpu": [
369
+ "x64"
370
+ ],
371
+ "dev": true,
372
+ "license": "MIT",
373
+ "optional": true,
374
+ "os": [
375
+ "android"
376
+ ],
377
+ "engines": {
378
+ "node": ">=18"
379
+ }
380
+ },
381
+ "node_modules/@esbuild/darwin-arm64": {
382
+ "version": "0.25.12",
383
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
384
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
385
+ "cpu": [
386
+ "arm64"
387
+ ],
388
+ "dev": true,
389
+ "license": "MIT",
390
+ "optional": true,
391
+ "os": [
392
+ "darwin"
393
+ ],
394
+ "engines": {
395
+ "node": ">=18"
396
+ }
397
+ },
398
+ "node_modules/@esbuild/darwin-x64": {
399
+ "version": "0.25.12",
400
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
401
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
402
+ "cpu": [
403
+ "x64"
404
+ ],
405
+ "dev": true,
406
+ "license": "MIT",
407
+ "optional": true,
408
+ "os": [
409
+ "darwin"
410
+ ],
411
+ "engines": {
412
+ "node": ">=18"
413
+ }
414
+ },
415
+ "node_modules/@esbuild/freebsd-arm64": {
416
+ "version": "0.25.12",
417
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
418
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
419
+ "cpu": [
420
+ "arm64"
421
+ ],
422
+ "dev": true,
423
+ "license": "MIT",
424
+ "optional": true,
425
+ "os": [
426
+ "freebsd"
427
+ ],
428
+ "engines": {
429
+ "node": ">=18"
430
+ }
431
+ },
432
+ "node_modules/@esbuild/freebsd-x64": {
433
+ "version": "0.25.12",
434
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
435
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
436
+ "cpu": [
437
+ "x64"
438
+ ],
439
+ "dev": true,
440
+ "license": "MIT",
441
+ "optional": true,
442
+ "os": [
443
+ "freebsd"
444
+ ],
445
+ "engines": {
446
+ "node": ">=18"
447
+ }
448
+ },
449
+ "node_modules/@esbuild/linux-arm": {
450
+ "version": "0.25.12",
451
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
452
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
453
+ "cpu": [
454
+ "arm"
455
+ ],
456
+ "dev": true,
457
+ "license": "MIT",
458
+ "optional": true,
459
+ "os": [
460
+ "linux"
461
+ ],
462
+ "engines": {
463
+ "node": ">=18"
464
+ }
465
+ },
466
+ "node_modules/@esbuild/linux-arm64": {
467
+ "version": "0.25.12",
468
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
469
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
470
+ "cpu": [
471
+ "arm64"
472
+ ],
473
+ "dev": true,
474
+ "license": "MIT",
475
+ "optional": true,
476
+ "os": [
477
+ "linux"
478
+ ],
479
+ "engines": {
480
+ "node": ">=18"
481
+ }
482
+ },
483
+ "node_modules/@esbuild/linux-ia32": {
484
+ "version": "0.25.12",
485
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
486
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
487
+ "cpu": [
488
+ "ia32"
489
+ ],
490
+ "dev": true,
491
+ "license": "MIT",
492
+ "optional": true,
493
+ "os": [
494
+ "linux"
495
+ ],
496
+ "engines": {
497
+ "node": ">=18"
498
+ }
499
+ },
500
+ "node_modules/@esbuild/linux-loong64": {
501
+ "version": "0.25.12",
502
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
503
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
504
+ "cpu": [
505
+ "loong64"
506
+ ],
507
+ "dev": true,
508
+ "license": "MIT",
509
+ "optional": true,
510
+ "os": [
511
+ "linux"
512
+ ],
513
+ "engines": {
514
+ "node": ">=18"
515
+ }
516
+ },
517
+ "node_modules/@esbuild/linux-mips64el": {
518
+ "version": "0.25.12",
519
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
520
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
521
+ "cpu": [
522
+ "mips64el"
523
+ ],
524
+ "dev": true,
525
+ "license": "MIT",
526
+ "optional": true,
527
+ "os": [
528
+ "linux"
529
+ ],
530
+ "engines": {
531
+ "node": ">=18"
532
+ }
533
+ },
534
+ "node_modules/@esbuild/linux-ppc64": {
535
+ "version": "0.25.12",
536
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
537
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
538
+ "cpu": [
539
+ "ppc64"
540
+ ],
541
+ "dev": true,
542
+ "license": "MIT",
543
+ "optional": true,
544
+ "os": [
545
+ "linux"
546
+ ],
547
+ "engines": {
548
+ "node": ">=18"
549
+ }
550
+ },
551
+ "node_modules/@esbuild/linux-riscv64": {
552
+ "version": "0.25.12",
553
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
554
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
555
+ "cpu": [
556
+ "riscv64"
557
+ ],
558
+ "dev": true,
559
+ "license": "MIT",
560
+ "optional": true,
561
+ "os": [
562
+ "linux"
563
+ ],
564
+ "engines": {
565
+ "node": ">=18"
566
+ }
567
+ },
568
+ "node_modules/@esbuild/linux-s390x": {
569
+ "version": "0.25.12",
570
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
571
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
572
+ "cpu": [
573
+ "s390x"
574
+ ],
575
+ "dev": true,
576
+ "license": "MIT",
577
+ "optional": true,
578
+ "os": [
579
+ "linux"
580
+ ],
581
+ "engines": {
582
+ "node": ">=18"
583
+ }
584
+ },
585
+ "node_modules/@esbuild/linux-x64": {
586
+ "version": "0.25.12",
587
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
588
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
589
+ "cpu": [
590
+ "x64"
591
+ ],
592
+ "dev": true,
593
+ "license": "MIT",
594
+ "optional": true,
595
+ "os": [
596
+ "linux"
597
+ ],
598
+ "engines": {
599
+ "node": ">=18"
600
+ }
601
+ },
602
+ "node_modules/@esbuild/netbsd-arm64": {
603
+ "version": "0.25.12",
604
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
605
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
606
+ "cpu": [
607
+ "arm64"
608
+ ],
609
+ "dev": true,
610
+ "license": "MIT",
611
+ "optional": true,
612
+ "os": [
613
+ "netbsd"
614
+ ],
615
+ "engines": {
616
+ "node": ">=18"
617
+ }
618
+ },
619
+ "node_modules/@esbuild/netbsd-x64": {
620
+ "version": "0.25.12",
621
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
622
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
623
+ "cpu": [
624
+ "x64"
625
+ ],
626
+ "dev": true,
627
+ "license": "MIT",
628
+ "optional": true,
629
+ "os": [
630
+ "netbsd"
631
+ ],
632
+ "engines": {
633
+ "node": ">=18"
634
+ }
635
+ },
636
+ "node_modules/@esbuild/openbsd-arm64": {
637
+ "version": "0.25.12",
638
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
639
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
640
+ "cpu": [
641
+ "arm64"
642
+ ],
643
+ "dev": true,
644
+ "license": "MIT",
645
+ "optional": true,
646
+ "os": [
647
+ "openbsd"
648
+ ],
649
+ "engines": {
650
+ "node": ">=18"
651
+ }
652
+ },
653
+ "node_modules/@esbuild/openbsd-x64": {
654
+ "version": "0.25.12",
655
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
656
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
657
+ "cpu": [
658
+ "x64"
659
+ ],
660
+ "dev": true,
661
+ "license": "MIT",
662
+ "optional": true,
663
+ "os": [
664
+ "openbsd"
665
+ ],
666
+ "engines": {
667
+ "node": ">=18"
668
+ }
669
+ },
670
+ "node_modules/@esbuild/openharmony-arm64": {
671
+ "version": "0.25.12",
672
+ "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
673
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
674
+ "cpu": [
675
+ "arm64"
676
+ ],
677
+ "dev": true,
678
+ "license": "MIT",
679
+ "optional": true,
680
+ "os": [
681
+ "openharmony"
682
+ ],
683
+ "engines": {
684
+ "node": ">=18"
685
+ }
686
+ },
687
+ "node_modules/@esbuild/sunos-x64": {
688
+ "version": "0.25.12",
689
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
690
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
691
+ "cpu": [
692
+ "x64"
693
+ ],
694
+ "dev": true,
695
+ "license": "MIT",
696
+ "optional": true,
697
+ "os": [
698
+ "sunos"
699
+ ],
700
+ "engines": {
701
+ "node": ">=18"
702
+ }
703
+ },
704
+ "node_modules/@esbuild/win32-arm64": {
705
+ "version": "0.25.12",
706
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
707
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
708
+ "cpu": [
709
+ "arm64"
710
+ ],
711
+ "dev": true,
712
+ "license": "MIT",
713
+ "optional": true,
714
+ "os": [
715
+ "win32"
716
+ ],
717
+ "engines": {
718
+ "node": ">=18"
719
+ }
720
+ },
721
+ "node_modules/@esbuild/win32-ia32": {
722
+ "version": "0.25.12",
723
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
724
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
725
+ "cpu": [
726
+ "ia32"
727
+ ],
728
+ "dev": true,
729
+ "license": "MIT",
730
+ "optional": true,
731
+ "os": [
732
+ "win32"
733
+ ],
734
+ "engines": {
735
+ "node": ">=18"
736
+ }
737
+ },
738
+ "node_modules/@esbuild/win32-x64": {
739
+ "version": "0.25.12",
740
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
741
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
742
+ "cpu": [
743
+ "x64"
744
+ ],
745
+ "dev": true,
746
+ "license": "MIT",
747
+ "optional": true,
748
+ "os": [
749
+ "win32"
750
+ ],
751
+ "engines": {
752
+ "node": ">=18"
753
+ }
754
+ },
755
+ "node_modules/@jridgewell/gen-mapping": {
756
+ "version": "0.3.13",
757
+ "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
758
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
759
+ "dev": true,
760
+ "license": "MIT",
761
+ "dependencies": {
762
+ "@jridgewell/sourcemap-codec": "^1.5.0",
763
+ "@jridgewell/trace-mapping": "^0.3.24"
764
+ }
765
+ },
766
+ "node_modules/@jridgewell/remapping": {
767
+ "version": "2.3.5",
768
+ "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
769
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
770
+ "dev": true,
771
+ "license": "MIT",
772
+ "dependencies": {
773
+ "@jridgewell/gen-mapping": "^0.3.5",
774
+ "@jridgewell/trace-mapping": "^0.3.24"
775
+ }
776
+ },
777
+ "node_modules/@jridgewell/resolve-uri": {
778
+ "version": "3.1.2",
779
+ "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
780
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
781
+ "dev": true,
782
+ "license": "MIT",
783
+ "engines": {
784
+ "node": ">=6.0.0"
785
+ }
786
+ },
787
+ "node_modules/@jridgewell/sourcemap-codec": {
788
+ "version": "1.5.5",
789
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
790
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
791
+ "dev": true,
792
+ "license": "MIT"
793
+ },
794
+ "node_modules/@jridgewell/trace-mapping": {
795
+ "version": "0.3.31",
796
+ "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
797
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
798
+ "dev": true,
799
+ "license": "MIT",
800
+ "dependencies": {
801
+ "@jridgewell/resolve-uri": "^3.1.0",
802
+ "@jridgewell/sourcemap-codec": "^1.4.14"
803
+ }
804
+ },
805
+ "node_modules/@pixiv/three-vrm": {
806
+ "version": "3.5.4",
807
+ "resolved": "https://registry.npmmirror.com/@pixiv/three-vrm/-/three-vrm-3.5.4.tgz",
808
+ "integrity": "sha512-hY0MnmKVLUebFR9QN9vCRXo0rZmLpxjLVZAupQv5qDDGCKZuNxlOts9PqTkWlzhkMLLdhB48AF+P4PCy/DFOzA==",
809
+ "license": "MIT",
810
+ "dependencies": {
811
+ "@pixiv/three-vrm-core": "3.5.4",
812
+ "@pixiv/three-vrm-materials-hdr-emissive-multiplier": "3.5.4",
813
+ "@pixiv/three-vrm-materials-mtoon": "3.5.4",
814
+ "@pixiv/three-vrm-materials-v0compat": "3.5.4",
815
+ "@pixiv/three-vrm-node-constraint": "3.5.4",
816
+ "@pixiv/three-vrm-springbone": "3.5.4"
817
+ },
818
+ "peerDependencies": {
819
+ "three": ">=0.137"
820
+ }
821
+ },
822
+ "node_modules/@pixiv/three-vrm-animation": {
823
+ "version": "3.5.4",
824
+ "resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-animation/-/three-vrm-animation-3.5.4.tgz",
825
+ "integrity": "sha512-yVJL7LoVwZ0fsW2SJhfa0bqIMcfTzBKyc1q/LeKqAtEv63BEQ9VUudx5ikd4zIkgvXwjxw7vGl2zvedrexFisg==",
826
+ "license": "MIT",
827
+ "dependencies": {
828
+ "@pixiv/three-vrm-core": "3.5.4",
829
+ "@pixiv/types-vrmc-vrm-1.0": "3.5.4",
830
+ "@pixiv/types-vrmc-vrm-animation-1.0": "3.5.4"
831
+ },
832
+ "peerDependencies": {
833
+ "three": ">=0.137"
834
+ }
835
+ },
836
+ "node_modules/@pixiv/three-vrm-core": {
837
+ "version": "3.5.4",
838
+ "resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-core/-/three-vrm-core-3.5.4.tgz",
839
+ "integrity": "sha512-CgaxZ4qX6JmE2oKsOVGGlheT011qP4bBZGUaMJJ5iQINI999+cveIdISe459B+AMYD1dU5h1+xCtDthLQNH1Bg==",
840
+ "license": "MIT",
841
+ "dependencies": {
842
+ "@pixiv/types-vrm-0.0": "3.5.4",
843
+ "@pixiv/types-vrmc-vrm-1.0": "3.5.4"
844
+ },
845
+ "peerDependencies": {
846
+ "three": ">=0.137"
847
+ }
848
+ },
849
+ "node_modules/@pixiv/three-vrm-materials-hdr-emissive-multiplier": {
850
+ "version": "3.5.4",
851
+ "resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-materials-hdr-emissive-multiplier/-/three-vrm-materials-hdr-emissive-multiplier-3.5.4.tgz",
852
+ "integrity": "sha512-3BPZ42qW38cHhP+imqEnqTFsltvYkHA/t4VzWFdQ9sngWt0NiFAOotEsv8gZn1ZgKE9VRQezXne6s5aRWQlQqA==",
853
+ "license": "MIT",
854
+ "dependencies": {
855
+ "@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": "3.5.4"
856
+ },
857
+ "peerDependencies": {
858
+ "three": ">=0.137"
859
+ }
860
+ },
861
+ "node_modules/@pixiv/three-vrm-materials-mtoon": {
862
+ "version": "3.5.4",
863
+ "resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-materials-mtoon/-/three-vrm-materials-mtoon-3.5.4.tgz",
864
+ "integrity": "sha512-vLHt7IYZxlijbCMa5TRRf6gaQjA65F/d7oZBNfZW3XC8sBg57ZV0M/xBAJm2d6UwVmTbBd9NG9li/Y9yuupDQw==",
865
+ "license": "MIT",
866
+ "dependencies": {
867
+ "@pixiv/types-vrm-0.0": "3.5.4",
868
+ "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.4"
869
+ },
870
+ "peerDependencies": {
871
+ "three": ">=0.137"
872
+ }
873
+ },
874
+ "node_modules/@pixiv/three-vrm-materials-v0compat": {
875
+ "version": "3.5.4",
876
+ "resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-materials-v0compat/-/three-vrm-materials-v0compat-3.5.4.tgz",
877
+ "integrity": "sha512-qRRtg8vYFBRJpsa3evruWqmXr0Gwd8uJXwxxUKuKtrIN0Hw0hJsRLHkg9E238Q8xRTnoycdkSgX4VvVknvtXBA==",
878
+ "license": "MIT",
879
+ "dependencies": {
880
+ "@pixiv/types-vrm-0.0": "3.5.4",
881
+ "@pixiv/types-vrmc-materials-mtoon-1.0": "3.5.4"
882
+ },
883
+ "peerDependencies": {
884
+ "three": ">=0.137"
885
+ }
886
+ },
887
+ "node_modules/@pixiv/three-vrm-node-constraint": {
888
+ "version": "3.5.4",
889
+ "resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-node-constraint/-/three-vrm-node-constraint-3.5.4.tgz",
890
+ "integrity": "sha512-nyAghDrYNp0Z2siEaY2+th+FzZdDs9EJy8K576Mpw9cy3jwtCJuTHBQmGzKkrgoLaBfrso+3Tdfgr62AJV0m1w==",
891
+ "license": "MIT",
892
+ "dependencies": {
893
+ "@pixiv/types-vrmc-node-constraint-1.0": "3.5.4"
894
+ },
895
+ "peerDependencies": {
896
+ "three": ">=0.137"
897
+ }
898
+ },
899
+ "node_modules/@pixiv/three-vrm-springbone": {
900
+ "version": "3.5.4",
901
+ "resolved": "https://registry.npmmirror.com/@pixiv/three-vrm-springbone/-/three-vrm-springbone-3.5.4.tgz",
902
+ "integrity": "sha512-8KWA7vHU+OnW6XcZPnDuUrjjPFRzmv2yxcTMNCYQvBS547/Iqo0RLbQvpO/ppceLTNJFY2TGXAWJU67aeazWjw==",
903
+ "license": "MIT",
904
+ "dependencies": {
905
+ "@pixiv/types-vrm-0.0": "3.5.4",
906
+ "@pixiv/types-vrmc-springbone-1.0": "3.5.4",
907
+ "@pixiv/types-vrmc-springbone-extended-collider-1.0": "3.5.4"
908
+ },
909
+ "peerDependencies": {
910
+ "three": ">=0.137"
911
+ }
912
+ },
913
+ "node_modules/@pixiv/types-vrm-0.0": {
914
+ "version": "3.5.4",
915
+ "resolved": "https://registry.npmmirror.com/@pixiv/types-vrm-0.0/-/types-vrm-0.0-3.5.4.tgz",
916
+ "integrity": "sha512-g9VMPikJxKJ/XgnhXxnqj6ejVhQ9WwOEUp7KyyZHsqRnHcQfSqyZFTs9wkAMCbwCqHXX/lg9+9Ogj8KhxCkbXw==",
917
+ "license": "MIT"
918
+ },
919
+ "node_modules/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0": {
920
+ "version": "3.5.4",
921
+ "resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-materials-hdr-emissive-multiplier-1.0/-/types-vrmc-materials-hdr-emissive-multiplier-1.0-3.5.4.tgz",
922
+ "integrity": "sha512-h9GEQ3q1VTylL/P40kJ8uoQhfUYY54NhTG6Xsnl4X0jf7oHh8MKXYnMMn903hXml6082Yjx1C6x3DKckdnophg==",
923
+ "license": "MIT"
924
+ },
925
+ "node_modules/@pixiv/types-vrmc-materials-mtoon-1.0": {
926
+ "version": "3.5.4",
927
+ "resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-materials-mtoon-1.0/-/types-vrmc-materials-mtoon-1.0-3.5.4.tgz",
928
+ "integrity": "sha512-AU5sOcsbmcnzRPfgfk7fzKhk5lNm2MqrbvtAsSlhylVMdlzrmXqIfRuexABcB9k+ysTp49qIFioot9KRlQcDUw==",
929
+ "license": "MIT"
930
+ },
931
+ "node_modules/@pixiv/types-vrmc-node-constraint-1.0": {
932
+ "version": "3.5.4",
933
+ "resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-node-constraint-1.0/-/types-vrmc-node-constraint-1.0-3.5.4.tgz",
934
+ "integrity": "sha512-RRbK5NNvZv4ewRELezueCiDB11FGkt4pdZR/UJ027DJPaNzd6rls2OEJ8weQ3OmgHZpbx/BtdpX2JWX5WZZd/Q==",
935
+ "license": "MIT"
936
+ },
937
+ "node_modules/@pixiv/types-vrmc-springbone-1.0": {
938
+ "version": "3.5.4",
939
+ "resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-springbone-1.0/-/types-vrmc-springbone-1.0-3.5.4.tgz",
940
+ "integrity": "sha512-NO7HTRBuWEe89Wo9BRI5hX1kWVkZzA4YWg9XALSTLdIT8HMiArL4NRse/CF91jNuLcTGfPEWlBJyq9SciAQxAg==",
941
+ "license": "MIT"
942
+ },
943
+ "node_modules/@pixiv/types-vrmc-springbone-extended-collider-1.0": {
944
+ "version": "3.5.4",
945
+ "resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-springbone-extended-collider-1.0/-/types-vrmc-springbone-extended-collider-1.0-3.5.4.tgz",
946
+ "integrity": "sha512-uzJmcRh/iHYnZPtnq6N1+jb+T/ptYNCyFEoArkWGNXHSfPjTkMcheTQkL5iMNvTE6hqhyMRVMha9OmbvyLmmUQ==",
947
+ "license": "MIT"
948
+ },
949
+ "node_modules/@pixiv/types-vrmc-vrm-1.0": {
950
+ "version": "3.5.4",
951
+ "resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-vrm-1.0/-/types-vrmc-vrm-1.0-3.5.4.tgz",
952
+ "integrity": "sha512-pkjT4QXT/Hp6rcq8J8EFEHIldbtPGYWOTaWdoRGMA8/KzJI5PdekZ6AUYbnSP6ElfJ3BSZ01DxxpQZa/UNsFqA==",
953
+ "license": "MIT"
954
+ },
955
+ "node_modules/@pixiv/types-vrmc-vrm-animation-1.0": {
956
+ "version": "3.5.4",
957
+ "resolved": "https://registry.npmmirror.com/@pixiv/types-vrmc-vrm-animation-1.0/-/types-vrmc-vrm-animation-1.0-3.5.4.tgz",
958
+ "integrity": "sha512-qIl/RO+WnWcBlu/PdTpPtm+YRum8RNtNNigndd/V9dJ9mWeFVI6TThc5i+qbFQjTF/I5ka0HojfeujesIYc1IA==",
959
+ "license": "MIT",
960
+ "dependencies": {
961
+ "@pixiv/types-vrmc-vrm-1.0": "3.5.4"
962
+ }
963
+ },
964
+ "node_modules/@rolldown/pluginutils": {
965
+ "version": "1.0.0-beta.27",
966
+ "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
967
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
968
+ "dev": true,
969
+ "license": "MIT"
970
+ },
971
+ "node_modules/@rollup/rollup-android-arm-eabi": {
972
+ "version": "4.62.2",
973
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
974
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
975
+ "cpu": [
976
+ "arm"
977
+ ],
978
+ "dev": true,
979
+ "license": "MIT",
980
+ "optional": true,
981
+ "os": [
982
+ "android"
983
+ ]
984
+ },
985
+ "node_modules/@rollup/rollup-android-arm64": {
986
+ "version": "4.62.2",
987
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
988
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
989
+ "cpu": [
990
+ "arm64"
991
+ ],
992
+ "dev": true,
993
+ "license": "MIT",
994
+ "optional": true,
995
+ "os": [
996
+ "android"
997
+ ]
998
+ },
999
+ "node_modules/@rollup/rollup-darwin-arm64": {
1000
+ "version": "4.62.2",
1001
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
1002
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
1003
+ "cpu": [
1004
+ "arm64"
1005
+ ],
1006
+ "dev": true,
1007
+ "license": "MIT",
1008
+ "optional": true,
1009
+ "os": [
1010
+ "darwin"
1011
+ ]
1012
+ },
1013
+ "node_modules/@rollup/rollup-darwin-x64": {
1014
+ "version": "4.62.2",
1015
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
1016
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
1017
+ "cpu": [
1018
+ "x64"
1019
+ ],
1020
+ "dev": true,
1021
+ "license": "MIT",
1022
+ "optional": true,
1023
+ "os": [
1024
+ "darwin"
1025
+ ]
1026
+ },
1027
+ "node_modules/@rollup/rollup-freebsd-arm64": {
1028
+ "version": "4.62.2",
1029
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
1030
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
1031
+ "cpu": [
1032
+ "arm64"
1033
+ ],
1034
+ "dev": true,
1035
+ "license": "MIT",
1036
+ "optional": true,
1037
+ "os": [
1038
+ "freebsd"
1039
+ ]
1040
+ },
1041
+ "node_modules/@rollup/rollup-freebsd-x64": {
1042
+ "version": "4.62.2",
1043
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
1044
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
1045
+ "cpu": [
1046
+ "x64"
1047
+ ],
1048
+ "dev": true,
1049
+ "license": "MIT",
1050
+ "optional": true,
1051
+ "os": [
1052
+ "freebsd"
1053
+ ]
1054
+ },
1055
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
1056
+ "version": "4.62.2",
1057
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
1058
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
1059
+ "cpu": [
1060
+ "arm"
1061
+ ],
1062
+ "dev": true,
1063
+ "libc": [
1064
+ "glibc"
1065
+ ],
1066
+ "license": "MIT",
1067
+ "optional": true,
1068
+ "os": [
1069
+ "linux"
1070
+ ]
1071
+ },
1072
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
1073
+ "version": "4.62.2",
1074
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
1075
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
1076
+ "cpu": [
1077
+ "arm"
1078
+ ],
1079
+ "dev": true,
1080
+ "libc": [
1081
+ "musl"
1082
+ ],
1083
+ "license": "MIT",
1084
+ "optional": true,
1085
+ "os": [
1086
+ "linux"
1087
+ ]
1088
+ },
1089
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
1090
+ "version": "4.62.2",
1091
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
1092
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
1093
+ "cpu": [
1094
+ "arm64"
1095
+ ],
1096
+ "dev": true,
1097
+ "libc": [
1098
+ "glibc"
1099
+ ],
1100
+ "license": "MIT",
1101
+ "optional": true,
1102
+ "os": [
1103
+ "linux"
1104
+ ]
1105
+ },
1106
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
1107
+ "version": "4.62.2",
1108
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
1109
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
1110
+ "cpu": [
1111
+ "arm64"
1112
+ ],
1113
+ "dev": true,
1114
+ "libc": [
1115
+ "musl"
1116
+ ],
1117
+ "license": "MIT",
1118
+ "optional": true,
1119
+ "os": [
1120
+ "linux"
1121
+ ]
1122
+ },
1123
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
1124
+ "version": "4.62.2",
1125
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
1126
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
1127
+ "cpu": [
1128
+ "loong64"
1129
+ ],
1130
+ "dev": true,
1131
+ "libc": [
1132
+ "glibc"
1133
+ ],
1134
+ "license": "MIT",
1135
+ "optional": true,
1136
+ "os": [
1137
+ "linux"
1138
+ ]
1139
+ },
1140
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
1141
+ "version": "4.62.2",
1142
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
1143
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
1144
+ "cpu": [
1145
+ "loong64"
1146
+ ],
1147
+ "dev": true,
1148
+ "libc": [
1149
+ "musl"
1150
+ ],
1151
+ "license": "MIT",
1152
+ "optional": true,
1153
+ "os": [
1154
+ "linux"
1155
+ ]
1156
+ },
1157
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
1158
+ "version": "4.62.2",
1159
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
1160
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
1161
+ "cpu": [
1162
+ "ppc64"
1163
+ ],
1164
+ "dev": true,
1165
+ "libc": [
1166
+ "glibc"
1167
+ ],
1168
+ "license": "MIT",
1169
+ "optional": true,
1170
+ "os": [
1171
+ "linux"
1172
+ ]
1173
+ },
1174
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
1175
+ "version": "4.62.2",
1176
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
1177
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
1178
+ "cpu": [
1179
+ "ppc64"
1180
+ ],
1181
+ "dev": true,
1182
+ "libc": [
1183
+ "musl"
1184
+ ],
1185
+ "license": "MIT",
1186
+ "optional": true,
1187
+ "os": [
1188
+ "linux"
1189
+ ]
1190
+ },
1191
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1192
+ "version": "4.62.2",
1193
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
1194
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
1195
+ "cpu": [
1196
+ "riscv64"
1197
+ ],
1198
+ "dev": true,
1199
+ "libc": [
1200
+ "glibc"
1201
+ ],
1202
+ "license": "MIT",
1203
+ "optional": true,
1204
+ "os": [
1205
+ "linux"
1206
+ ]
1207
+ },
1208
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
1209
+ "version": "4.62.2",
1210
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
1211
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
1212
+ "cpu": [
1213
+ "riscv64"
1214
+ ],
1215
+ "dev": true,
1216
+ "libc": [
1217
+ "musl"
1218
+ ],
1219
+ "license": "MIT",
1220
+ "optional": true,
1221
+ "os": [
1222
+ "linux"
1223
+ ]
1224
+ },
1225
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
1226
+ "version": "4.62.2",
1227
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
1228
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
1229
+ "cpu": [
1230
+ "s390x"
1231
+ ],
1232
+ "dev": true,
1233
+ "libc": [
1234
+ "glibc"
1235
+ ],
1236
+ "license": "MIT",
1237
+ "optional": true,
1238
+ "os": [
1239
+ "linux"
1240
+ ]
1241
+ },
1242
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1243
+ "version": "4.62.2",
1244
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
1245
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
1246
+ "cpu": [
1247
+ "x64"
1248
+ ],
1249
+ "dev": true,
1250
+ "libc": [
1251
+ "glibc"
1252
+ ],
1253
+ "license": "MIT",
1254
+ "optional": true,
1255
+ "os": [
1256
+ "linux"
1257
+ ]
1258
+ },
1259
+ "node_modules/@rollup/rollup-linux-x64-musl": {
1260
+ "version": "4.62.2",
1261
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
1262
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
1263
+ "cpu": [
1264
+ "x64"
1265
+ ],
1266
+ "dev": true,
1267
+ "libc": [
1268
+ "musl"
1269
+ ],
1270
+ "license": "MIT",
1271
+ "optional": true,
1272
+ "os": [
1273
+ "linux"
1274
+ ]
1275
+ },
1276
+ "node_modules/@rollup/rollup-openbsd-x64": {
1277
+ "version": "4.62.2",
1278
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
1279
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
1280
+ "cpu": [
1281
+ "x64"
1282
+ ],
1283
+ "dev": true,
1284
+ "license": "MIT",
1285
+ "optional": true,
1286
+ "os": [
1287
+ "openbsd"
1288
+ ]
1289
+ },
1290
+ "node_modules/@rollup/rollup-openharmony-arm64": {
1291
+ "version": "4.62.2",
1292
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
1293
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
1294
+ "cpu": [
1295
+ "arm64"
1296
+ ],
1297
+ "dev": true,
1298
+ "license": "MIT",
1299
+ "optional": true,
1300
+ "os": [
1301
+ "openharmony"
1302
+ ]
1303
+ },
1304
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1305
+ "version": "4.62.2",
1306
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
1307
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
1308
+ "cpu": [
1309
+ "arm64"
1310
+ ],
1311
+ "dev": true,
1312
+ "license": "MIT",
1313
+ "optional": true,
1314
+ "os": [
1315
+ "win32"
1316
+ ]
1317
+ },
1318
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1319
+ "version": "4.62.2",
1320
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
1321
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
1322
+ "cpu": [
1323
+ "ia32"
1324
+ ],
1325
+ "dev": true,
1326
+ "license": "MIT",
1327
+ "optional": true,
1328
+ "os": [
1329
+ "win32"
1330
+ ]
1331
+ },
1332
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1333
+ "version": "4.62.2",
1334
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
1335
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
1336
+ "cpu": [
1337
+ "x64"
1338
+ ],
1339
+ "dev": true,
1340
+ "license": "MIT",
1341
+ "optional": true,
1342
+ "os": [
1343
+ "win32"
1344
+ ]
1345
+ },
1346
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1347
+ "version": "4.62.2",
1348
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
1349
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
1350
+ "cpu": [
1351
+ "x64"
1352
+ ],
1353
+ "dev": true,
1354
+ "license": "MIT",
1355
+ "optional": true,
1356
+ "os": [
1357
+ "win32"
1358
+ ]
1359
+ },
1360
+ "node_modules/@tauri-apps/cli": {
1361
+ "version": "2.11.3",
1362
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.11.3.tgz",
1363
+ "integrity": "sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==",
1364
+ "dev": true,
1365
+ "license": "Apache-2.0 OR MIT",
1366
+ "bin": {
1367
+ "tauri": "tauri.js"
1368
+ },
1369
+ "engines": {
1370
+ "node": ">= 10"
1371
+ },
1372
+ "funding": {
1373
+ "type": "opencollective",
1374
+ "url": "https://opencollective.com/tauri"
1375
+ },
1376
+ "optionalDependencies": {
1377
+ "@tauri-apps/cli-darwin-arm64": "2.11.3",
1378
+ "@tauri-apps/cli-darwin-x64": "2.11.3",
1379
+ "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.3",
1380
+ "@tauri-apps/cli-linux-arm64-gnu": "2.11.3",
1381
+ "@tauri-apps/cli-linux-arm64-musl": "2.11.3",
1382
+ "@tauri-apps/cli-linux-riscv64-gnu": "2.11.3",
1383
+ "@tauri-apps/cli-linux-x64-gnu": "2.11.3",
1384
+ "@tauri-apps/cli-linux-x64-musl": "2.11.3",
1385
+ "@tauri-apps/cli-win32-arm64-msvc": "2.11.3",
1386
+ "@tauri-apps/cli-win32-ia32-msvc": "2.11.3",
1387
+ "@tauri-apps/cli-win32-x64-msvc": "2.11.3"
1388
+ }
1389
+ },
1390
+ "node_modules/@tauri-apps/cli-darwin-arm64": {
1391
+ "version": "2.11.3",
1392
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.3.tgz",
1393
+ "integrity": "sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==",
1394
+ "cpu": [
1395
+ "arm64"
1396
+ ],
1397
+ "dev": true,
1398
+ "license": "Apache-2.0 OR MIT",
1399
+ "optional": true,
1400
+ "os": [
1401
+ "darwin"
1402
+ ],
1403
+ "engines": {
1404
+ "node": ">= 10"
1405
+ }
1406
+ },
1407
+ "node_modules/@tauri-apps/cli-darwin-x64": {
1408
+ "version": "2.11.3",
1409
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.3.tgz",
1410
+ "integrity": "sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==",
1411
+ "cpu": [
1412
+ "x64"
1413
+ ],
1414
+ "dev": true,
1415
+ "license": "Apache-2.0 OR MIT",
1416
+ "optional": true,
1417
+ "os": [
1418
+ "darwin"
1419
+ ],
1420
+ "engines": {
1421
+ "node": ">= 10"
1422
+ }
1423
+ },
1424
+ "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
1425
+ "version": "2.11.3",
1426
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.3.tgz",
1427
+ "integrity": "sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==",
1428
+ "cpu": [
1429
+ "arm"
1430
+ ],
1431
+ "dev": true,
1432
+ "license": "Apache-2.0 OR MIT",
1433
+ "optional": true,
1434
+ "os": [
1435
+ "linux"
1436
+ ],
1437
+ "engines": {
1438
+ "node": ">= 10"
1439
+ }
1440
+ },
1441
+ "node_modules/@tauri-apps/cli-linux-arm64-gnu": {
1442
+ "version": "2.11.3",
1443
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.3.tgz",
1444
+ "integrity": "sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==",
1445
+ "cpu": [
1446
+ "arm64"
1447
+ ],
1448
+ "dev": true,
1449
+ "libc": [
1450
+ "glibc"
1451
+ ],
1452
+ "license": "Apache-2.0 OR MIT",
1453
+ "optional": true,
1454
+ "os": [
1455
+ "linux"
1456
+ ],
1457
+ "engines": {
1458
+ "node": ">= 10"
1459
+ }
1460
+ },
1461
+ "node_modules/@tauri-apps/cli-linux-arm64-musl": {
1462
+ "version": "2.11.3",
1463
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.3.tgz",
1464
+ "integrity": "sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==",
1465
+ "cpu": [
1466
+ "arm64"
1467
+ ],
1468
+ "dev": true,
1469
+ "libc": [
1470
+ "musl"
1471
+ ],
1472
+ "license": "Apache-2.0 OR MIT",
1473
+ "optional": true,
1474
+ "os": [
1475
+ "linux"
1476
+ ],
1477
+ "engines": {
1478
+ "node": ">= 10"
1479
+ }
1480
+ },
1481
+ "node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
1482
+ "version": "2.11.3",
1483
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.3.tgz",
1484
+ "integrity": "sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==",
1485
+ "cpu": [
1486
+ "riscv64"
1487
+ ],
1488
+ "dev": true,
1489
+ "libc": [
1490
+ "glibc"
1491
+ ],
1492
+ "license": "Apache-2.0 OR MIT",
1493
+ "optional": true,
1494
+ "os": [
1495
+ "linux"
1496
+ ],
1497
+ "engines": {
1498
+ "node": ">= 10"
1499
+ }
1500
+ },
1501
+ "node_modules/@tauri-apps/cli-linux-x64-gnu": {
1502
+ "version": "2.11.3",
1503
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.3.tgz",
1504
+ "integrity": "sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==",
1505
+ "cpu": [
1506
+ "x64"
1507
+ ],
1508
+ "dev": true,
1509
+ "libc": [
1510
+ "glibc"
1511
+ ],
1512
+ "license": "Apache-2.0 OR MIT",
1513
+ "optional": true,
1514
+ "os": [
1515
+ "linux"
1516
+ ],
1517
+ "engines": {
1518
+ "node": ">= 10"
1519
+ }
1520
+ },
1521
+ "node_modules/@tauri-apps/cli-linux-x64-musl": {
1522
+ "version": "2.11.3",
1523
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.3.tgz",
1524
+ "integrity": "sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==",
1525
+ "cpu": [
1526
+ "x64"
1527
+ ],
1528
+ "dev": true,
1529
+ "libc": [
1530
+ "musl"
1531
+ ],
1532
+ "license": "Apache-2.0 OR MIT",
1533
+ "optional": true,
1534
+ "os": [
1535
+ "linux"
1536
+ ],
1537
+ "engines": {
1538
+ "node": ">= 10"
1539
+ }
1540
+ },
1541
+ "node_modules/@tauri-apps/cli-win32-arm64-msvc": {
1542
+ "version": "2.11.3",
1543
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.3.tgz",
1544
+ "integrity": "sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==",
1545
+ "cpu": [
1546
+ "arm64"
1547
+ ],
1548
+ "dev": true,
1549
+ "license": "Apache-2.0 OR MIT",
1550
+ "optional": true,
1551
+ "os": [
1552
+ "win32"
1553
+ ],
1554
+ "engines": {
1555
+ "node": ">= 10"
1556
+ }
1557
+ },
1558
+ "node_modules/@tauri-apps/cli-win32-ia32-msvc": {
1559
+ "version": "2.11.3",
1560
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.3.tgz",
1561
+ "integrity": "sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==",
1562
+ "cpu": [
1563
+ "ia32"
1564
+ ],
1565
+ "dev": true,
1566
+ "license": "Apache-2.0 OR MIT",
1567
+ "optional": true,
1568
+ "os": [
1569
+ "win32"
1570
+ ],
1571
+ "engines": {
1572
+ "node": ">= 10"
1573
+ }
1574
+ },
1575
+ "node_modules/@tauri-apps/cli-win32-x64-msvc": {
1576
+ "version": "2.11.3",
1577
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.3.tgz",
1578
+ "integrity": "sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==",
1579
+ "cpu": [
1580
+ "x64"
1581
+ ],
1582
+ "dev": true,
1583
+ "license": "Apache-2.0 OR MIT",
1584
+ "optional": true,
1585
+ "os": [
1586
+ "win32"
1587
+ ],
1588
+ "engines": {
1589
+ "node": ">= 10"
1590
+ }
1591
+ },
1592
+ "node_modules/@tweenjs/tween.js": {
1593
+ "version": "23.1.3",
1594
+ "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
1595
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
1596
+ "dev": true,
1597
+ "license": "MIT"
1598
+ },
1599
+ "node_modules/@types/babel__core": {
1600
+ "version": "7.20.5",
1601
+ "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
1602
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1603
+ "dev": true,
1604
+ "license": "MIT",
1605
+ "dependencies": {
1606
+ "@babel/parser": "^7.20.7",
1607
+ "@babel/types": "^7.20.7",
1608
+ "@types/babel__generator": "*",
1609
+ "@types/babel__template": "*",
1610
+ "@types/babel__traverse": "*"
1611
+ }
1612
+ },
1613
+ "node_modules/@types/babel__generator": {
1614
+ "version": "7.27.0",
1615
+ "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1616
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1617
+ "dev": true,
1618
+ "license": "MIT",
1619
+ "dependencies": {
1620
+ "@babel/types": "^7.0.0"
1621
+ }
1622
+ },
1623
+ "node_modules/@types/babel__template": {
1624
+ "version": "7.4.4",
1625
+ "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz",
1626
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1627
+ "dev": true,
1628
+ "license": "MIT",
1629
+ "dependencies": {
1630
+ "@babel/parser": "^7.1.0",
1631
+ "@babel/types": "^7.0.0"
1632
+ }
1633
+ },
1634
+ "node_modules/@types/babel__traverse": {
1635
+ "version": "7.28.0",
1636
+ "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1637
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1638
+ "dev": true,
1639
+ "license": "MIT",
1640
+ "dependencies": {
1641
+ "@babel/types": "^7.28.2"
1642
+ }
1643
+ },
1644
+ "node_modules/@types/estree": {
1645
+ "version": "1.0.9",
1646
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
1647
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1648
+ "dev": true,
1649
+ "license": "MIT"
1650
+ },
1651
+ "node_modules/@types/prop-types": {
1652
+ "version": "15.7.15",
1653
+ "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz",
1654
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1655
+ "dev": true,
1656
+ "license": "MIT"
1657
+ },
1658
+ "node_modules/@types/react": {
1659
+ "version": "18.3.31",
1660
+ "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz",
1661
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1662
+ "dev": true,
1663
+ "license": "MIT",
1664
+ "dependencies": {
1665
+ "@types/prop-types": "*",
1666
+ "csstype": "^3.2.2"
1667
+ }
1668
+ },
1669
+ "node_modules/@types/react-dom": {
1670
+ "version": "18.3.7",
1671
+ "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz",
1672
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1673
+ "dev": true,
1674
+ "license": "MIT",
1675
+ "peerDependencies": {
1676
+ "@types/react": "^18.0.0"
1677
+ }
1678
+ },
1679
+ "node_modules/@types/stats.js": {
1680
+ "version": "0.17.4",
1681
+ "resolved": "https://registry.npmmirror.com/@types/stats.js/-/stats.js-0.17.4.tgz",
1682
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
1683
+ "dev": true,
1684
+ "license": "MIT"
1685
+ },
1686
+ "node_modules/@types/three": {
1687
+ "version": "0.175.0",
1688
+ "resolved": "https://registry.npmmirror.com/@types/three/-/three-0.175.0.tgz",
1689
+ "integrity": "sha512-ldMSBgtZOZ3g9kJ3kOZSEtZIEITmJOzu8eKVpkhf036GuNkM4mt0NXecrjCn5tMm1OblOF7dZehlaDypBfNokw==",
1690
+ "dev": true,
1691
+ "license": "MIT",
1692
+ "dependencies": {
1693
+ "@tweenjs/tween.js": "~23.1.3",
1694
+ "@types/stats.js": "*",
1695
+ "@types/webxr": "*",
1696
+ "@webgpu/types": "*",
1697
+ "fflate": "~0.8.2",
1698
+ "meshoptimizer": "~0.18.1"
1699
+ }
1700
+ },
1701
+ "node_modules/@types/webxr": {
1702
+ "version": "0.5.24",
1703
+ "resolved": "https://registry.npmmirror.com/@types/webxr/-/webxr-0.5.24.tgz",
1704
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
1705
+ "dev": true,
1706
+ "license": "MIT"
1707
+ },
1708
+ "node_modules/@vitejs/plugin-react": {
1709
+ "version": "4.7.0",
1710
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1711
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1712
+ "dev": true,
1713
+ "license": "MIT",
1714
+ "dependencies": {
1715
+ "@babel/core": "^7.28.0",
1716
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1717
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1718
+ "@rolldown/pluginutils": "1.0.0-beta.27",
1719
+ "@types/babel__core": "^7.20.5",
1720
+ "react-refresh": "^0.17.0"
1721
+ },
1722
+ "engines": {
1723
+ "node": "^14.18.0 || >=16.0.0"
1724
+ },
1725
+ "peerDependencies": {
1726
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1727
+ }
1728
+ },
1729
+ "node_modules/@webgpu/types": {
1730
+ "version": "0.1.70",
1731
+ "resolved": "https://registry.npmmirror.com/@webgpu/types/-/types-0.1.70.tgz",
1732
+ "integrity": "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==",
1733
+ "dev": true,
1734
+ "license": "BSD-3-Clause"
1735
+ },
1736
+ "node_modules/baseline-browser-mapping": {
1737
+ "version": "2.10.38",
1738
+ "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
1739
+ "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
1740
+ "dev": true,
1741
+ "license": "Apache-2.0",
1742
+ "bin": {
1743
+ "baseline-browser-mapping": "dist/cli.cjs"
1744
+ },
1745
+ "engines": {
1746
+ "node": ">=6.0.0"
1747
+ }
1748
+ },
1749
+ "node_modules/browserslist": {
1750
+ "version": "4.28.2",
1751
+ "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz",
1752
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
1753
+ "dev": true,
1754
+ "funding": [
1755
+ {
1756
+ "type": "opencollective",
1757
+ "url": "https://opencollective.com/browserslist"
1758
+ },
1759
+ {
1760
+ "type": "tidelift",
1761
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1762
+ },
1763
+ {
1764
+ "type": "github",
1765
+ "url": "https://github.com/sponsors/ai"
1766
+ }
1767
+ ],
1768
+ "license": "MIT",
1769
+ "dependencies": {
1770
+ "baseline-browser-mapping": "^2.10.12",
1771
+ "caniuse-lite": "^1.0.30001782",
1772
+ "electron-to-chromium": "^1.5.328",
1773
+ "node-releases": "^2.0.36",
1774
+ "update-browserslist-db": "^1.2.3"
1775
+ },
1776
+ "bin": {
1777
+ "browserslist": "cli.js"
1778
+ },
1779
+ "engines": {
1780
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1781
+ }
1782
+ },
1783
+ "node_modules/caniuse-lite": {
1784
+ "version": "1.0.30001799",
1785
+ "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
1786
+ "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
1787
+ "dev": true,
1788
+ "funding": [
1789
+ {
1790
+ "type": "opencollective",
1791
+ "url": "https://opencollective.com/browserslist"
1792
+ },
1793
+ {
1794
+ "type": "tidelift",
1795
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1796
+ },
1797
+ {
1798
+ "type": "github",
1799
+ "url": "https://github.com/sponsors/ai"
1800
+ }
1801
+ ],
1802
+ "license": "CC-BY-4.0"
1803
+ },
1804
+ "node_modules/convert-source-map": {
1805
+ "version": "2.0.0",
1806
+ "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz",
1807
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1808
+ "dev": true,
1809
+ "license": "MIT"
1810
+ },
1811
+ "node_modules/csstype": {
1812
+ "version": "3.2.3",
1813
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
1814
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1815
+ "dev": true,
1816
+ "license": "MIT"
1817
+ },
1818
+ "node_modules/debug": {
1819
+ "version": "4.4.3",
1820
+ "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
1821
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1822
+ "dev": true,
1823
+ "license": "MIT",
1824
+ "dependencies": {
1825
+ "ms": "^2.1.3"
1826
+ },
1827
+ "engines": {
1828
+ "node": ">=6.0"
1829
+ },
1830
+ "peerDependenciesMeta": {
1831
+ "supports-color": {
1832
+ "optional": true
1833
+ }
1834
+ }
1835
+ },
1836
+ "node_modules/electron-to-chromium": {
1837
+ "version": "1.5.376",
1838
+ "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
1839
+ "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==",
1840
+ "dev": true,
1841
+ "license": "ISC"
1842
+ },
1843
+ "node_modules/esbuild": {
1844
+ "version": "0.25.12",
1845
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
1846
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
1847
+ "dev": true,
1848
+ "hasInstallScript": true,
1849
+ "license": "MIT",
1850
+ "bin": {
1851
+ "esbuild": "bin/esbuild"
1852
+ },
1853
+ "engines": {
1854
+ "node": ">=18"
1855
+ },
1856
+ "optionalDependencies": {
1857
+ "@esbuild/aix-ppc64": "0.25.12",
1858
+ "@esbuild/android-arm": "0.25.12",
1859
+ "@esbuild/android-arm64": "0.25.12",
1860
+ "@esbuild/android-x64": "0.25.12",
1861
+ "@esbuild/darwin-arm64": "0.25.12",
1862
+ "@esbuild/darwin-x64": "0.25.12",
1863
+ "@esbuild/freebsd-arm64": "0.25.12",
1864
+ "@esbuild/freebsd-x64": "0.25.12",
1865
+ "@esbuild/linux-arm": "0.25.12",
1866
+ "@esbuild/linux-arm64": "0.25.12",
1867
+ "@esbuild/linux-ia32": "0.25.12",
1868
+ "@esbuild/linux-loong64": "0.25.12",
1869
+ "@esbuild/linux-mips64el": "0.25.12",
1870
+ "@esbuild/linux-ppc64": "0.25.12",
1871
+ "@esbuild/linux-riscv64": "0.25.12",
1872
+ "@esbuild/linux-s390x": "0.25.12",
1873
+ "@esbuild/linux-x64": "0.25.12",
1874
+ "@esbuild/netbsd-arm64": "0.25.12",
1875
+ "@esbuild/netbsd-x64": "0.25.12",
1876
+ "@esbuild/openbsd-arm64": "0.25.12",
1877
+ "@esbuild/openbsd-x64": "0.25.12",
1878
+ "@esbuild/openharmony-arm64": "0.25.12",
1879
+ "@esbuild/sunos-x64": "0.25.12",
1880
+ "@esbuild/win32-arm64": "0.25.12",
1881
+ "@esbuild/win32-ia32": "0.25.12",
1882
+ "@esbuild/win32-x64": "0.25.12"
1883
+ }
1884
+ },
1885
+ "node_modules/escalade": {
1886
+ "version": "3.2.0",
1887
+ "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
1888
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1889
+ "dev": true,
1890
+ "license": "MIT",
1891
+ "engines": {
1892
+ "node": ">=6"
1893
+ }
1894
+ },
1895
+ "node_modules/fdir": {
1896
+ "version": "6.5.0",
1897
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
1898
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
1899
+ "dev": true,
1900
+ "license": "MIT",
1901
+ "engines": {
1902
+ "node": ">=12.0.0"
1903
+ },
1904
+ "peerDependencies": {
1905
+ "picomatch": "^3 || ^4"
1906
+ },
1907
+ "peerDependenciesMeta": {
1908
+ "picomatch": {
1909
+ "optional": true
1910
+ }
1911
+ }
1912
+ },
1913
+ "node_modules/fflate": {
1914
+ "version": "0.8.3",
1915
+ "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
1916
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
1917
+ "dev": true,
1918
+ "license": "MIT"
1919
+ },
1920
+ "node_modules/fsevents": {
1921
+ "version": "2.3.3",
1922
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
1923
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1924
+ "dev": true,
1925
+ "hasInstallScript": true,
1926
+ "license": "MIT",
1927
+ "optional": true,
1928
+ "os": [
1929
+ "darwin"
1930
+ ],
1931
+ "engines": {
1932
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1933
+ }
1934
+ },
1935
+ "node_modules/gensync": {
1936
+ "version": "1.0.0-beta.2",
1937
+ "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
1938
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1939
+ "dev": true,
1940
+ "license": "MIT",
1941
+ "engines": {
1942
+ "node": ">=6.9.0"
1943
+ }
1944
+ },
1945
+ "node_modules/js-tokens": {
1946
+ "version": "4.0.0",
1947
+ "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
1948
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1949
+ "license": "MIT"
1950
+ },
1951
+ "node_modules/jsesc": {
1952
+ "version": "3.1.0",
1953
+ "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
1954
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1955
+ "dev": true,
1956
+ "license": "MIT",
1957
+ "bin": {
1958
+ "jsesc": "bin/jsesc"
1959
+ },
1960
+ "engines": {
1961
+ "node": ">=6"
1962
+ }
1963
+ },
1964
+ "node_modules/json5": {
1965
+ "version": "2.2.3",
1966
+ "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz",
1967
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1968
+ "dev": true,
1969
+ "license": "MIT",
1970
+ "bin": {
1971
+ "json5": "lib/cli.js"
1972
+ },
1973
+ "engines": {
1974
+ "node": ">=6"
1975
+ }
1976
+ },
1977
+ "node_modules/loose-envify": {
1978
+ "version": "1.4.0",
1979
+ "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
1980
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1981
+ "license": "MIT",
1982
+ "dependencies": {
1983
+ "js-tokens": "^3.0.0 || ^4.0.0"
1984
+ },
1985
+ "bin": {
1986
+ "loose-envify": "cli.js"
1987
+ }
1988
+ },
1989
+ "node_modules/lru-cache": {
1990
+ "version": "5.1.1",
1991
+ "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
1992
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1993
+ "dev": true,
1994
+ "license": "ISC",
1995
+ "dependencies": {
1996
+ "yallist": "^3.0.2"
1997
+ }
1998
+ },
1999
+ "node_modules/lucide-react": {
2000
+ "version": "0.577.0",
2001
+ "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.577.0.tgz",
2002
+ "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
2003
+ "license": "ISC",
2004
+ "peerDependencies": {
2005
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
2006
+ }
2007
+ },
2008
+ "node_modules/meshoptimizer": {
2009
+ "version": "0.18.1",
2010
+ "resolved": "https://registry.npmmirror.com/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
2011
+ "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
2012
+ "dev": true,
2013
+ "license": "MIT"
2014
+ },
2015
+ "node_modules/mmd-parser": {
2016
+ "version": "1.0.4",
2017
+ "resolved": "https://registry.npmmirror.com/mmd-parser/-/mmd-parser-1.0.4.tgz",
2018
+ "integrity": "sha512-Qi0VCU46t2IwfGv5KF0+D/t9cizcDug7qnNoy9Ggk7aucp0tssV8IwTMkBlDbm+VqAf3cdQHTCARKSsuS2MYFg==",
2019
+ "license": "MIT"
2020
+ },
2021
+ "node_modules/ms": {
2022
+ "version": "2.1.3",
2023
+ "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
2024
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
2025
+ "dev": true,
2026
+ "license": "MIT"
2027
+ },
2028
+ "node_modules/nanoid": {
2029
+ "version": "3.3.13",
2030
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.13.tgz",
2031
+ "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
2032
+ "dev": true,
2033
+ "funding": [
2034
+ {
2035
+ "type": "github",
2036
+ "url": "https://github.com/sponsors/ai"
2037
+ }
2038
+ ],
2039
+ "license": "MIT",
2040
+ "bin": {
2041
+ "nanoid": "bin/nanoid.cjs"
2042
+ },
2043
+ "engines": {
2044
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
2045
+ }
2046
+ },
2047
+ "node_modules/node-releases": {
2048
+ "version": "2.0.48",
2049
+ "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.48.tgz",
2050
+ "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
2051
+ "dev": true,
2052
+ "license": "MIT",
2053
+ "engines": {
2054
+ "node": ">=18"
2055
+ }
2056
+ },
2057
+ "node_modules/picocolors": {
2058
+ "version": "1.1.1",
2059
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
2060
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2061
+ "dev": true,
2062
+ "license": "ISC"
2063
+ },
2064
+ "node_modules/picomatch": {
2065
+ "version": "4.0.4",
2066
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz",
2067
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2068
+ "dev": true,
2069
+ "license": "MIT",
2070
+ "engines": {
2071
+ "node": ">=12"
2072
+ },
2073
+ "funding": {
2074
+ "url": "https://github.com/sponsors/jonschlinkert"
2075
+ }
2076
+ },
2077
+ "node_modules/postcss": {
2078
+ "version": "8.5.15",
2079
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
2080
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
2081
+ "dev": true,
2082
+ "funding": [
2083
+ {
2084
+ "type": "opencollective",
2085
+ "url": "https://opencollective.com/postcss/"
2086
+ },
2087
+ {
2088
+ "type": "tidelift",
2089
+ "url": "https://tidelift.com/funding/github/npm/postcss"
2090
+ },
2091
+ {
2092
+ "type": "github",
2093
+ "url": "https://github.com/sponsors/ai"
2094
+ }
2095
+ ],
2096
+ "license": "MIT",
2097
+ "dependencies": {
2098
+ "nanoid": "^3.3.12",
2099
+ "picocolors": "^1.1.1",
2100
+ "source-map-js": "^1.2.1"
2101
+ },
2102
+ "engines": {
2103
+ "node": "^10 || ^12 || >=14"
2104
+ }
2105
+ },
2106
+ "node_modules/react": {
2107
+ "version": "18.3.1",
2108
+ "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
2109
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
2110
+ "license": "MIT",
2111
+ "dependencies": {
2112
+ "loose-envify": "^1.1.0"
2113
+ },
2114
+ "engines": {
2115
+ "node": ">=0.10.0"
2116
+ }
2117
+ },
2118
+ "node_modules/react-dom": {
2119
+ "version": "18.3.1",
2120
+ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz",
2121
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
2122
+ "license": "MIT",
2123
+ "dependencies": {
2124
+ "loose-envify": "^1.1.0",
2125
+ "scheduler": "^0.23.2"
2126
+ },
2127
+ "peerDependencies": {
2128
+ "react": "^18.3.1"
2129
+ }
2130
+ },
2131
+ "node_modules/react-refresh": {
2132
+ "version": "0.17.0",
2133
+ "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
2134
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
2135
+ "dev": true,
2136
+ "license": "MIT",
2137
+ "engines": {
2138
+ "node": ">=0.10.0"
2139
+ }
2140
+ },
2141
+ "node_modules/rollup": {
2142
+ "version": "4.62.2",
2143
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
2144
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
2145
+ "dev": true,
2146
+ "license": "MIT",
2147
+ "dependencies": {
2148
+ "@types/estree": "1.0.9"
2149
+ },
2150
+ "bin": {
2151
+ "rollup": "dist/bin/rollup"
2152
+ },
2153
+ "engines": {
2154
+ "node": ">=18.0.0",
2155
+ "npm": ">=8.0.0"
2156
+ },
2157
+ "optionalDependencies": {
2158
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
2159
+ "@rollup/rollup-android-arm64": "4.62.2",
2160
+ "@rollup/rollup-darwin-arm64": "4.62.2",
2161
+ "@rollup/rollup-darwin-x64": "4.62.2",
2162
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
2163
+ "@rollup/rollup-freebsd-x64": "4.62.2",
2164
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
2165
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
2166
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
2167
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
2168
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
2169
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
2170
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
2171
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
2172
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
2173
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
2174
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
2175
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
2176
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
2177
+ "@rollup/rollup-openbsd-x64": "4.62.2",
2178
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
2179
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
2180
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
2181
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
2182
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
2183
+ "fsevents": "~2.3.2"
2184
+ }
2185
+ },
2186
+ "node_modules/scheduler": {
2187
+ "version": "0.23.2",
2188
+ "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz",
2189
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
2190
+ "license": "MIT",
2191
+ "dependencies": {
2192
+ "loose-envify": "^1.1.0"
2193
+ }
2194
+ },
2195
+ "node_modules/semver": {
2196
+ "version": "6.3.1",
2197
+ "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
2198
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2199
+ "dev": true,
2200
+ "license": "ISC",
2201
+ "bin": {
2202
+ "semver": "bin/semver.js"
2203
+ }
2204
+ },
2205
+ "node_modules/source-map-js": {
2206
+ "version": "1.2.1",
2207
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
2208
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2209
+ "dev": true,
2210
+ "license": "BSD-3-Clause",
2211
+ "engines": {
2212
+ "node": ">=0.10.0"
2213
+ }
2214
+ },
2215
+ "node_modules/three": {
2216
+ "version": "0.175.0",
2217
+ "resolved": "https://registry.npmmirror.com/three/-/three-0.175.0.tgz",
2218
+ "integrity": "sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==",
2219
+ "license": "MIT"
2220
+ },
2221
+ "node_modules/tinyglobby": {
2222
+ "version": "0.2.17",
2223
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
2224
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
2225
+ "dev": true,
2226
+ "license": "MIT",
2227
+ "dependencies": {
2228
+ "fdir": "^6.5.0",
2229
+ "picomatch": "^4.0.4"
2230
+ },
2231
+ "engines": {
2232
+ "node": ">=12.0.0"
2233
+ },
2234
+ "funding": {
2235
+ "url": "https://github.com/sponsors/SuperchupuDev"
2236
+ }
2237
+ },
2238
+ "node_modules/typescript": {
2239
+ "version": "5.9.3",
2240
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
2241
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2242
+ "dev": true,
2243
+ "license": "Apache-2.0",
2244
+ "bin": {
2245
+ "tsc": "bin/tsc",
2246
+ "tsserver": "bin/tsserver"
2247
+ },
2248
+ "engines": {
2249
+ "node": ">=14.17"
2250
+ }
2251
+ },
2252
+ "node_modules/update-browserslist-db": {
2253
+ "version": "1.2.3",
2254
+ "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2255
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2256
+ "dev": true,
2257
+ "funding": [
2258
+ {
2259
+ "type": "opencollective",
2260
+ "url": "https://opencollective.com/browserslist"
2261
+ },
2262
+ {
2263
+ "type": "tidelift",
2264
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2265
+ },
2266
+ {
2267
+ "type": "github",
2268
+ "url": "https://github.com/sponsors/ai"
2269
+ }
2270
+ ],
2271
+ "license": "MIT",
2272
+ "dependencies": {
2273
+ "escalade": "^3.2.0",
2274
+ "picocolors": "^1.1.1"
2275
+ },
2276
+ "bin": {
2277
+ "update-browserslist-db": "cli.js"
2278
+ },
2279
+ "peerDependencies": {
2280
+ "browserslist": ">= 4.21.0"
2281
+ }
2282
+ },
2283
+ "node_modules/vite": {
2284
+ "version": "6.4.3",
2285
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
2286
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
2287
+ "dev": true,
2288
+ "license": "MIT",
2289
+ "dependencies": {
2290
+ "esbuild": "^0.25.0",
2291
+ "fdir": "^6.4.4",
2292
+ "picomatch": "^4.0.2",
2293
+ "postcss": "^8.5.3",
2294
+ "rollup": "^4.34.9",
2295
+ "tinyglobby": "^0.2.13"
2296
+ },
2297
+ "bin": {
2298
+ "vite": "bin/vite.js"
2299
+ },
2300
+ "engines": {
2301
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2302
+ },
2303
+ "funding": {
2304
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2305
+ },
2306
+ "optionalDependencies": {
2307
+ "fsevents": "~2.3.3"
2308
+ },
2309
+ "peerDependencies": {
2310
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2311
+ "jiti": ">=1.21.0",
2312
+ "less": "*",
2313
+ "lightningcss": "^1.21.0",
2314
+ "sass": "*",
2315
+ "sass-embedded": "*",
2316
+ "stylus": "*",
2317
+ "sugarss": "*",
2318
+ "terser": "^5.16.0",
2319
+ "tsx": "^4.8.1",
2320
+ "yaml": "^2.4.2"
2321
+ },
2322
+ "peerDependenciesMeta": {
2323
+ "@types/node": {
2324
+ "optional": true
2325
+ },
2326
+ "jiti": {
2327
+ "optional": true
2328
+ },
2329
+ "less": {
2330
+ "optional": true
2331
+ },
2332
+ "lightningcss": {
2333
+ "optional": true
2334
+ },
2335
+ "sass": {
2336
+ "optional": true
2337
+ },
2338
+ "sass-embedded": {
2339
+ "optional": true
2340
+ },
2341
+ "stylus": {
2342
+ "optional": true
2343
+ },
2344
+ "sugarss": {
2345
+ "optional": true
2346
+ },
2347
+ "terser": {
2348
+ "optional": true
2349
+ },
2350
+ "tsx": {
2351
+ "optional": true
2352
+ },
2353
+ "yaml": {
2354
+ "optional": true
2355
+ }
2356
+ }
2357
+ },
2358
+ "node_modules/wlipsync": {
2359
+ "version": "1.3.0",
2360
+ "resolved": "https://registry.npmmirror.com/wlipsync/-/wlipsync-1.3.0.tgz",
2361
+ "integrity": "sha512-wYF85QAWCQE2rma9qoXok5we77XouCKxNh0HvbzRAtSIhc/QZbSh2rh5vKgWYHD4jrTE8+CwaxvU9oEUqYO9Zw==",
2362
+ "license": "MIT"
2363
+ },
2364
+ "node_modules/yallist": {
2365
+ "version": "3.1.1",
2366
+ "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
2367
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2368
+ "dev": true,
2369
+ "license": "ISC"
2370
+ }
2371
+ }
2372
+ }
friend/frontend/package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "friend-vrm-frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build"
9
+ },
10
+ "dependencies": {
11
+ "@pixiv/three-vrm": "^3.4.0",
12
+ "@pixiv/three-vrm-animation": "^3.5.0",
13
+ "@pixiv/three-vrm-core": "^3.5.1",
14
+ "lucide-react": "^0.577.0",
15
+ "mmd-parser": "^1.0.4",
16
+ "react": "^18.3.1",
17
+ "react-dom": "^18.3.1",
18
+ "three": "^0.175.0",
19
+ "wlipsync": "^1.3.0"
20
+ },
21
+ "devDependencies": {
22
+ "@tauri-apps/cli": "^2.11.3",
23
+ "@types/react": "^18.3.23",
24
+ "@types/react-dom": "^18.3.7",
25
+ "@types/three": "^0.175.0",
26
+ "@vitejs/plugin-react": "^4.5.0",
27
+ "typescript": "^5.8.3",
28
+ "vite": "^6.3.2"
29
+ }
30
+ }
friend/frontend/public/akimbo.vrma ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6c9f7f0cbb37a9404800583dccfdd8e03b66b73b806c3d80265c5c7690710324
3
+ size 289136
friend/frontend/public/angry.fbx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bfc3a0d10d3e1918636a1151216098651680d9f9be41550fd0d725a5d639d884
3
+ size 1688752
friend/frontend/public/angryPump.fbx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:04e7ec62c958914f69df388a3ed8bd8e11cf14d48bbace28eda47e764374654f
3
+ size 632864
friend/frontend/public/excited.fbx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cdd3f99b18177a12b49bfd3731091d981bea0863282391fbd3f1229f08d16cda
3
+ size 826000
friend/frontend/public/greeting.fbx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a345411dd77faa101e6ac1bc977f6362be05382af6c811643ce7f2fc2c5d169
3
+ size 711008
friend/frontend/public/happy.fbx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a656a5da7309bde2e1159ef9e5bf35f3ce0d8088b3aadbfbb5eb99923bbe2c94
3
+ size 1095680
friend/frontend/public/idle_loop.vrma ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ace95ba6dcc0bdf2ed1081c002332b4184441117c8d543b6f642b3d2c5cf99be
3
+ size 157664
friend/frontend/public/jile.vmd ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5963cad3d5dd6ec43b1974816903694b3214a89443e06afc4b9633c898a5b42f
3
+ size 1994161
friend/frontend/public/love.vmd ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a6aff9a97ab4e5073c4fa28db84422dcce6276e3027127bd9c98bc8ad797c766
3
+ size 3116347
friend/frontend/public/playFingers.vrma ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f2ef8e98d7c4ea4f4d0b5f0973b5ae14a11f006981575913671416f4349a9caa
3
+ size 142692
friend/frontend/public/point.fbx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:863d7bf87428bbcb7d6a4d77e1b4fe1b9d7263cf3cde5ba5679fe431028ec5b4
3
+ size 538352
friend/frontend/public/salute.fbx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b9fd250487f99bb0bc39cfa0a2d83e0891b8ada4939437050b15bb4e6c33fd49
3
+ size 595056
friend/frontend/public/scratchHead.vrma ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:31ef24bc1bb2766a609a292157c3685e79dc844e532a4fa499bc1975650e2e4e
3
+ size 183472
friend/frontend/public/shy.fbx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6f3dd889a485efcb9b549f3c060600e329baf259e0971b16bc711cca898b2718
3
+ size 489216
friend/frontend/public/stretch.vrma ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4f5ab38707bc04802888644af6d4e8935c7cf23160ac129153f8c6b6f9ff9176
3
+ size 136032
friend/frontend/src-tauri/Cargo.lock ADDED
The diff for this file is too large to render. See raw diff
 
friend/frontend/src-tauri/Cargo.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "versperclaw-friend"
3
+ version = "0.1.0"
4
+ description = "VersperClaw VRM desktop pet"
5
+ authors = ["VersperClaw"]
6
+ edition = "2021"
7
+
8
+ [lib]
9
+ name = "versperclaw_friend_lib"
10
+ crate-type = ["lib", "cdylib", "staticlib"]
11
+
12
+ [build-dependencies]
13
+ tauri-build = { version = "2", features = [] }
14
+
15
+ [dependencies]
16
+ tauri = { version = "2", features = [] }
17
+ tauri-plugin-opener = "2"
18
+ serde = { version = "1", features = ["derive"] }
19
+ serde_json = "1"
20
+
21
+ [features]
22
+ default = ["custom-protocol"]
23
+ custom-protocol = ["tauri/custom-protocol"]
friend/frontend/src-tauri/build.rs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ fn main() {
2
+ tauri_build::build()
3
+ }
friend/frontend/src-tauri/capabilities/default.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "identifier": "default",
3
+ "description": "Default capabilities for VersperClaw Friend",
4
+ "windows": ["main"],
5
+ "permissions": [
6
+ "core:default",
7
+ "opener:default"
8
+ ]
9
+ }
friend/frontend/src-tauri/icons/128x128.png ADDED

Git LFS Details

  • SHA256: abed9d6d7efb363e64f404b9773c9aabbab7fc2e91039f31f117ef54be59262a
  • Pointer size: 129 Bytes
  • Size of remote file: 2.01 kB
friend/frontend/src-tauri/icons/256x256.png ADDED

Git LFS Details

  • SHA256: d1498c13855dc0290dec4e0a2df4065073ddcc3e6c844b3c121ff420aaf8aadb
  • Pointer size: 129 Bytes
  • Size of remote file: 3.66 kB
friend/frontend/src-tauri/icons/32x32.png ADDED

Git LFS Details

  • SHA256: 463ca82091efa940a0172fa29c918228ef20e88cba2ad8561708e5158afd843b
  • Pointer size: 128 Bytes
  • Size of remote file: 886 Bytes
friend/frontend/src-tauri/src/lib.rs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ pub fn run() {
2
+ tauri::Builder::default()
3
+ .plugin(tauri_plugin_opener::init())
4
+ .run(tauri::generate_context!())
5
+ .expect("error while running tauri application");
6
+ }
friend/frontend/src-tauri/src/main.rs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ // Prevents additional console window on Windows in release
2
+ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3
+
4
+ fn main() {
5
+ versperclaw_friend_lib::run()
6
+ }
friend/frontend/src-tauri/tauri.conf.json ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/nicedoc/tauri/dev/crates/tauri-config-schema/schema.json",
3
+ "productName": "VersperClaw Friend",
4
+ "version": "0.1.0",
5
+ "identifier": "com.versperclaw.friend",
6
+ "build": {
7
+ "devUrl": "http://127.0.0.1:3456/friend/",
8
+ "frontendDist": "../dist"
9
+ },
10
+ "bundle": {
11
+ "icon": [
12
+ "icons/32x32.png",
13
+ "icons/128x128.png",
14
+ "icons/256x256.png"
15
+ ],
16
+ "active": true
17
+ },
18
+ "app": {
19
+ "windows": [
20
+ {
21
+ "title": "VersperClaw Friend",
22
+ "label": "main",
23
+ "transparent": true,
24
+ "decorations": false,
25
+ "alwaysOnTop": true,
26
+ "width": 400,
27
+ "height": 600,
28
+ "minWidth": 200,
29
+ "minHeight": 300,
30
+ "resizable": false,
31
+ "skipTaskbar": false,
32
+ "center": true,
33
+ "visible": true
34
+ }
35
+ ],
36
+ "security": {
37
+ "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:3456 ws://127.0.0.1:3456; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' http://127.0.0.1:3456; font-src 'self' data:; script-src 'self' 'unsafe-inline';"
38
+ }
39
+ }
40
+ }
friend/frontend/src/App.tsx ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useState, useCallback } from 'react'
2
+ import { VRMScene } from './components/VRMScene'
3
+ import type { VRMSceneHandle, TouchRegion } from './components/VRMScene'
4
+ import { TextBubble } from './components/TextBubble'
5
+ import type { OnVrmMessage } from './components/TextBubble'
6
+ import { ChatInput } from './components/ChatInput'
7
+ import { ResizeHandles } from './components/ResizeHandles'
8
+ import { SettingsPanel } from './components/SettingsPanel'
9
+ import { usePassThrough } from './hooks/usePassThrough'
10
+ import { dancePresets } from './motion-controller'
11
+ import { LipSync } from './lip-sync'
12
+ import { FRIEND_API, bindScene } from './api'
13
+ import { HistoryPanel } from './components/HistoryPanel'
14
+ import { MoodIndicator } from './components/MoodIndicator'
15
+ import { Menu, Pin, Move, RotateCcw, Rotate3D, EyeOff, Settings, Music, RefreshCw } from 'lucide-react'
16
+
17
+ const DEFAULT_MODEL = '/friend/model1.vrm'
18
+ // Module-level to survive any component remount
19
+ let lastTouchMemoTime = 0
20
+ const TOUCH_MEMO_COOLDOWN = 3_000
21
+ let lastTouchChatTime = 0
22
+ const TOUCH_CHAT_COOLDOWN = 60_000
23
+
24
+ // 情绪 → 动作映射 (action names from motion-controller presets)
25
+ const emotionActionMap: Record<string, string> = {
26
+ think: 'scratchHead',
27
+ question: 'point',
28
+ curious: 'scratchHead',
29
+ happy: 'happy',
30
+ surprised: 'excited',
31
+ angry: 'angry',
32
+ awkward: 'playFingers',
33
+ sad: 'shy',
34
+ love: 'shy',
35
+ flirty: 'shy',
36
+ greeting: 'greeting',
37
+ relaxed: 'salute',
38
+ neutral: 'salute',
39
+ }
40
+
41
+
42
+ const btnStyle: React.CSSProperties = {
43
+ width: 32,
44
+ height: 32,
45
+ border: 'none',
46
+ borderRadius: 6,
47
+ background: 'rgba(125, 125, 125, 0.28)',
48
+ backdropFilter: 'blur(6px)',
49
+ display: 'flex',
50
+ alignItems: 'center',
51
+ justifyContent: 'center',
52
+ color: 'rgba(255, 255, 255, 0.8)',
53
+ fontSize: 16,
54
+ cursor: 'pointer',
55
+ }
56
+
57
+ export default function App() {
58
+ const sceneRef = useRef<VRMSceneHandle>(null)
59
+ const [pinned, setPinned] = useState(true)
60
+ const [tracking, setTracking] = useState<'mouse' | 'camera'>('mouse')
61
+ const [showText, setShowText] = useState(true)
62
+ const [collapsed, setCollapsed] = useState(false)
63
+
64
+ const [ttsEnabled, setTtsEnabled] = useState(true)
65
+ const [modelPath, setModelPath] = useState(DEFAULT_MODEL)
66
+ const [settingsOpen, setSettingsOpen] = useState(false)
67
+ const [historyOpen, setHistoryOpen] = useState(false)
68
+ const [hideUI, setHideUI] = useState(false)
69
+ const [volume, setVolume] = useState(0.5)
70
+ const [uiAlign, setUiAlign] = useState<'left' | 'right'>('right')
71
+ const [dancing, setDancing] = useState(false)
72
+ const [currentDance, setCurrentDance] = useState('jile')
73
+ const [customDancePreset, setCustomDancePreset] = useState<import('./motion-controller').DancePreset | undefined>(undefined)
74
+ const [hideMood, setHideMood] = useState(false)
75
+ const [screenObserve, setScreenObserve] = useState(false)
76
+ const [screenObserveInterval, setScreenObserveInterval] = useState(60)
77
+ const [language, setLanguage] = useState<'zh' | 'en'>(() => navigator.language.startsWith('zh') ? 'zh' : 'en')
78
+ const t = (zh: string, en: string) => language === 'en' ? en : zh
79
+ usePassThrough(!settingsOpen && !historyOpen)
80
+
81
+ // Load persisted settings on mount
82
+ useEffect(() => {
83
+ fetch(`${FRIEND_API}/settings`)
84
+ .then((r) => r.json())
85
+ .then((s) => {
86
+ if (s.modelPath) setModelPath(s.modelPath)
87
+ if (s.ttsEnabled !== undefined) setTtsEnabled(s.ttsEnabled)
88
+ if (s.showText !== undefined) setShowText(s.showText)
89
+ if (s.hideUI !== undefined) setHideUI(s.hideUI)
90
+ if (s.tracking) { setTracking(s.tracking); sceneRef.current?.setTrackingMode(s.tracking) }
91
+ if (s.volume !== undefined) { setVolume(s.volume); LipSync.getInstance().setVolume(s.volume); sceneRef.current?.setBgmVolume(s.volume) }
92
+ if (s.uiAlign) setUiAlign(s.uiAlign)
93
+ if (s.hideMood !== undefined) setHideMood(s.hideMood)
94
+ if (s.screenObserve !== undefined) setScreenObserve(s.screenObserve)
95
+ if (s.screenObserveInterval !== undefined) setScreenObserveInterval(s.screenObserveInterval)
96
+ if (s.currentDance) setCurrentDance(s.currentDance)
97
+ if (s.customDancePreset) setCustomDancePreset(s.customDancePreset)
98
+ if (s.language) {
99
+ setLanguage(s.language)
100
+ } else {
101
+ // No saved language — persist the detected system language to backend
102
+ const detected = navigator.language.startsWith('zh') ? 'zh' : 'en'
103
+ fetch(`${FRIEND_API}/settings`, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({ language: detected }),
107
+ }).catch(() => {})
108
+ }
109
+ })
110
+ .catch(() => {})
111
+ }, [])
112
+
113
+ const saveSettings = (patch: Record<string, unknown>) => {
114
+ fetch(`${FRIEND_API}/settings`, {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify(patch),
118
+ }).catch(() => {})
119
+ }
120
+
121
+ useEffect(() => {
122
+ // open-settings Tauri event replaced by F4 keyboard shortcut (handled below)
123
+ }, [])
124
+
125
+ useEffect(() => {
126
+ bindScene(sceneRef.current)
127
+ return () => bindScene(null)
128
+ })
129
+
130
+ const handleVolumeChange = useCallback((v: number) => {
131
+ setVolume(v)
132
+ LipSync.getInstance().setVolume(v)
133
+ sceneRef.current?.setBgmVolume(v)
134
+ saveSettings({ volume: v })
135
+ }, [])
136
+
137
+ const handleTrackingChange = useCallback((mode: 'mouse' | 'camera') => {
138
+ sceneRef.current?.setTrackingMode(mode)
139
+ setTracking(mode)
140
+ saveSettings({ tracking: mode })
141
+ }, [])
142
+
143
+ const handleVrmMessage: OnVrmMessage = useCallback((msg) => {
144
+ if (msg.emotion && sceneRef.current) {
145
+ const action = emotionActionMap[msg.emotion]
146
+ if (msg.text) {
147
+ // 回复消息:表情和动作同时触发(文字出现1s后由TextBubble延迟调用)
148
+ sceneRef.current.setEmotionWithReset(msg.emotion, msg.emotionDuration ?? 5000, msg.emotionIntensity)
149
+ if (action) sceneRef.current.playAction(action)
150
+ } else {
151
+ // 思考阶段:hold 动作,10s 后自动 reset
152
+ sceneRef.current.setEmotionWithReset(msg.emotion, msg.emotionDuration ?? 10000, msg.emotionIntensity)
153
+ if (action) sceneRef.current.playAction(action, true)
154
+ }
155
+ }
156
+ }, [])
157
+
158
+ // ── Idle fidget: random emotion + action every ~60s when idle ──────────────
159
+ const lastActivityRef = useRef(Date.now())
160
+ // Reset idle timer whenever a VRM message arrives
161
+ const originalHandleVrmMessage = handleVrmMessage
162
+ const handleVrmMessageWithActivity: OnVrmMessage = useCallback((msg) => {
163
+ lastActivityRef.current = Date.now()
164
+ originalHandleVrmMessage(msg)
165
+ }, [originalHandleVrmMessage])
166
+
167
+ useEffect(() => {
168
+ const allEmotions = [
169
+ 'happy', 'sad', 'angry', 'surprised', 'think', 'awkward',
170
+ 'question', 'curious', 'neutral', 'love', 'flirty', 'greeting', 'relaxed',
171
+ ]
172
+ const allActions = [
173
+ 'akimbo', 'playFingers', 'scratchHead', 'stretch',
174
+ 'happy', 'angry', 'greeting', 'excited', 'shy',
175
+ 'point', 'salute', 'angryPump',
176
+ ]
177
+ const IDLE_THRESHOLD_MS = 30_000
178
+ const FIDGET_CHECK_MS = 15_000 // check every 15s, randomness inside
179
+
180
+ const timer = setInterval(() => {
181
+ const idleMs = Date.now() - lastActivityRef.current
182
+ if (idleMs < IDLE_THRESHOLD_MS) return
183
+ // 50% chance each check to avoid being too predictable
184
+ if (Math.random() > 0.5) return
185
+
186
+ const emotion = allEmotions[Math.floor(Math.random() * allEmotions.length)]
187
+ const action = allActions[Math.floor(Math.random() * allActions.length)]
188
+ const intensity = 0.4 + Math.random() * 0.4 // 0.4–0.8
189
+ sceneRef.current?.setEmotionWithReset(emotion, 3000 + Math.random() * 2000, intensity)
190
+ sceneRef.current?.playAction(action)
191
+ lastActivityRef.current = Date.now() // reset so we don't spam
192
+ }, FIDGET_CHECK_MS)
193
+
194
+ return () => clearInterval(timer)
195
+ }, [])
196
+
197
+ // ── Dancing mood boost: +1 every 30s ─────────
198
+ useEffect(() => {
199
+ if (!dancing) return
200
+ const timer = setInterval(() => {
201
+ fetch(`${FRIEND_API}/mood/adjust`, {
202
+ method: 'POST',
203
+ headers: { 'Content-Type': 'application/json' },
204
+ body: JSON.stringify({ delta: 1, max: 90 }),
205
+ }).catch(() => {})
206
+ }, 30_000)
207
+ return () => clearInterval(timer)
208
+ }, [dancing])
209
+
210
+ // ── Screen observation: capture desktop & send to LLM periodically ─────────
211
+ useEffect(() => {
212
+ if (!screenObserve) return
213
+ const intervalMs = screenObserveInterval * 1000
214
+
215
+ const doObserve = () => {
216
+ fetch(`${FRIEND_API}/screen/observe`, { method: 'POST' })
217
+ .catch(() => {})
218
+ }
219
+
220
+ const timer = setInterval(doObserve, intervalMs)
221
+ // Also fire once immediately on enable
222
+ doObserve()
223
+
224
+ return () => clearInterval(timer)
225
+ }, [screenObserve, screenObserveInterval])
226
+
227
+ // Capture VRM screenshot and upload to server (debounced to avoid duplicates)
228
+ const screenshotTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
229
+ const uploadVrmScreenshot = useCallback(() => {
230
+ if (screenshotTimer.current) clearTimeout(screenshotTimer.current)
231
+ screenshotTimer.current = setTimeout(() => {
232
+ screenshotTimer.current = null
233
+ const dataUrl = sceneRef.current?.captureScreenshot()
234
+ if (!dataUrl) return
235
+ fetch(`${FRIEND_API}/persona/screenshot`, {
236
+ method: 'POST',
237
+ headers: { 'Content-Type': 'application/json' },
238
+ body: JSON.stringify({ image: dataUrl }),
239
+ }).catch(() => {})
240
+ }, 500)
241
+ }, [])
242
+
243
+ const clearContext = async () => {
244
+ try {
245
+ await fetch(`${FRIEND_API}/context/clear`, { method: 'POST' })
246
+ } catch { /* ignore */ }
247
+ }
248
+
249
+ // 全局快捷键: Tab 展开/折叠菜单
250
+ useEffect(() => {
251
+ const onKeyDown = (e: KeyboardEvent) => {
252
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
253
+ if (e.key === 'Tab') {
254
+ e.preventDefault()
255
+ setCollapsed((v) => !v)
256
+ }
257
+ if (e.key === 'F4') {
258
+ e.preventDefault()
259
+ setSettingsOpen((v) => !v)
260
+ }
261
+ if (e.key === 'F5') {
262
+ e.preventDefault()
263
+ window.location.reload()
264
+ }
265
+ }
266
+ window.addEventListener('keydown', onKeyDown)
267
+ return () => window.removeEventListener('keydown', onKeyDown)
268
+ }, [])
269
+
270
+ // ── Touch interaction: immediate reaction + verbal response ──────────
271
+ // Each region has multiple possible reactions, randomly selected (from lobe-vidol female defaults)
272
+ const touchReactions: Record<TouchRegion, { emotion: string; action?: string }[]> = {
273
+ head: [
274
+ { emotion: 'relaxed', action: 'happy' },
275
+ { emotion: 'relaxed', action: 'shy' },
276
+ { emotion: 'relaxed', action: 'happy' },
277
+ { emotion: 'relaxed', action: 'shy' },
278
+ { emotion: 'angry', action: 'angryPump' },
279
+ { emotion: 'relaxed', action: 'excited' },
280
+ ],
281
+ arm: [
282
+ { emotion: 'surprised', action: 'excited' },
283
+ { emotion: 'happy', action: 'happy' },
284
+ { emotion: 'relaxed', action: 'greeting' },
285
+ { emotion: 'relaxed', action: 'salute' },
286
+ { emotion: 'relaxed', action: 'akimbo' },
287
+ ],
288
+ chest: [
289
+ { emotion: 'angry', action: 'angryPump' },
290
+ { emotion: 'angry', action: 'angry' },
291
+ { emotion: 'angry', action: 'point' },
292
+ ],
293
+ belly: [
294
+ { emotion: 'angry', action: 'angryPump' },
295
+ { emotion: 'angry', action: 'angry' },
296
+ { emotion: 'awkward', action: 'playFingers' },
297
+ { emotion: 'angry', action: 'akimbo' },
298
+ ],
299
+ buttocks: [
300
+ { emotion: 'angry', action: 'angryPump' },
301
+ { emotion: 'angry', action: 'point' },
302
+ { emotion: 'sad', action: 'shy' },
303
+ ],
304
+ leg: [
305
+ { emotion: 'sad', action: 'shy' },
306
+ { emotion: 'angry', action: 'angry' },
307
+ { emotion: 'angry', action: 'point' },
308
+ { emotion: 'angry', action: 'angryPump' },
309
+ { emotion: 'awkward', action: 'playFingers' },
310
+ ],
311
+ }
312
+
313
+ const regionLabels: Record<TouchRegion, string> = language === 'en'
314
+ ? { head: 'head', arm: 'arm', chest: 'chest', belly: 'belly', buttocks: 'buttocks', leg: 'leg' }
315
+ : { head: '头', arm: '手臂', chest: '胸', belly: '肚子', buttocks: '屁股', leg: '腿' }
316
+
317
+ const handleTouch = useCallback((region: TouchRegion) => {
318
+ const reactions = touchReactions[region]
319
+ if (!reactions?.length) return
320
+ const visual = reactions[Math.floor(Math.random() * reactions.length)]
321
+
322
+ // Immediate visual feedback (always)
323
+ lastActivityRef.current = Date.now()
324
+ sceneRef.current?.setEmotionWithReset(visual.emotion, 3000, 0.8)
325
+ if (visual.action) sceneRef.current?.playAction(visual.action)
326
+
327
+ const now = Date.now()
328
+ const prompt = language === 'en'
329
+ ? `[The user touched your ${regionLabels[region]}]`
330
+ : `[用户摸了摸你的${regionLabels[region]}]`
331
+
332
+ // Always write to session history (3s cooldown)
333
+ if (now - lastTouchMemoTime > TOUCH_MEMO_COOLDOWN) {
334
+ lastTouchMemoTime = now
335
+ fetch(`${FRIEND_API}/session/memo`, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({ text: prompt }),
339
+ }).catch(() => {})
340
+ }
341
+
342
+ // Send to backend for verbal response (60s cooldown, 50% chance)
343
+ if (now - lastTouchChatTime > TOUCH_CHAT_COOLDOWN && Math.random() < 0.5) {
344
+ lastTouchChatTime = now
345
+ fetch(`${FRIEND_API}/touch`, {
346
+ method: 'POST',
347
+ headers: { 'Content-Type': 'application/json' },
348
+ body: JSON.stringify({ region, prompt }),
349
+ }).catch(() => {})
350
+ }
351
+ }, [])
352
+
353
+ const togglePin = async () => {
354
+ // Window pinning not available in web mode
355
+ setPinned((v) => !v)
356
+ }
357
+
358
+ return (
359
+ <div
360
+ style={{
361
+ width: '100vw',
362
+ height: '100vh',
363
+ background: 'transparent',
364
+ overflow: 'hidden',
365
+ position: 'relative',
366
+ }}
367
+ >
368
+ <ResizeHandles />
369
+ <VRMScene ref={sceneRef} modelPath={modelPath} onTouch={handleTouch} onModelLoaded={uploadVrmScreenshot} />
370
+ {!hideMood && <MoodIndicator uiAlign={uiAlign} />}
371
+ <TextBubble onMessage={handleVrmMessageWithActivity} enabled={showText} ttsEnabled={ttsEnabled} />
372
+ {!hideUI && <ChatInput uiAlign={uiAlign} onHistoryOpen={() => setHistoryOpen(true)} onNewSession={clearContext} language={language} />}
373
+ <HistoryPanel
374
+ visible={historyOpen}
375
+ onClose={() => setHistoryOpen(false)}
376
+ language={language}
377
+ />
378
+ <SettingsPanel
379
+ visible={settingsOpen}
380
+ onClose={() => setSettingsOpen(false)}
381
+ currentModel={modelPath}
382
+ onModelChange={(m) => { setModelPath(m); setDancing(false); saveSettings({ modelPath: m }) }}
383
+ hideUI={hideUI}
384
+ onHideUIChange={(v) => { setHideUI(v); saveSettings({ hideUI: v }) }}
385
+ showText={showText}
386
+ onShowTextChange={(v) => { setShowText(v); saveSettings({ showText: v }) }}
387
+ ttsEnabled={ttsEnabled}
388
+ onTtsEnabledChange={(v) => { setTtsEnabled(v); saveSettings({ ttsEnabled: v }) }}
389
+ tracking={tracking}
390
+ onTrackingChange={handleTrackingChange}
391
+ volume={volume}
392
+ onVolumeChange={handleVolumeChange}
393
+ uiAlign={uiAlign}
394
+ onUiAlignChange={(v) => { setUiAlign(v); saveSettings({ uiAlign: v }) }}
395
+ hideMood={hideMood}
396
+ onHideMoodChange={(v) => { setHideMood(v); saveSettings({ hideMood: v }) }}
397
+ screenObserve={screenObserve}
398
+ onScreenObserveChange={(v) => { setScreenObserve(v); saveSettings({ screenObserve: v }) }}
399
+ screenObserveInterval={screenObserveInterval}
400
+ onScreenObserveIntervalChange={(v) => { setScreenObserveInterval(v); saveSettings({ screenObserveInterval: v }) }}
401
+ captureVrmScreenshot={() => sceneRef.current?.captureScreenshot() ?? null}
402
+ language={language}
403
+ onLanguageChange={(v) => { setLanguage(v); saveSettings({ language: v }) }}
404
+ currentDance={currentDance}
405
+ onDanceChange={(id, preset) => {
406
+ setCurrentDance(id)
407
+ setCustomDancePreset(preset)
408
+ saveSettings({ currentDance: id, customDancePreset: preset })
409
+ }}
410
+ />
411
+ {!hideUI && <div
412
+ style={{
413
+ position: 'absolute',
414
+ top: 8,
415
+ ...(uiAlign === 'left' ? { left: 8 } : { right: 8 }),
416
+ display: 'flex',
417
+ flexDirection: 'column',
418
+ gap: 4,
419
+ }}
420
+ >
421
+ <button
422
+ onClick={() => setCollapsed((v) => !v)}
423
+ style={btnStyle}
424
+ title={collapsed ? t('展开菜单 (Tab)', 'Expand Menu (Tab)') : t('折叠菜单 (Tab)', 'Collapse Menu (Tab)')}
425
+ >
426
+ <Menu size={16} />
427
+ </button>
428
+ {!collapsed && <>
429
+ <button
430
+ onClick={() => setSettingsOpen(true)}
431
+ style={btnStyle}
432
+ title={t('设置 (F4)', 'Settings (F4)')}
433
+ >
434
+ <Settings size={16} />
435
+ </button>
436
+ <button
437
+ onClick={() => window.location.reload()}
438
+ style={btnStyle}
439
+ title={t('刷新 (F5)', 'Refresh (F5)')}
440
+ >
441
+ <RefreshCw size={16} />
442
+ </button>
443
+ <button
444
+ onClick={() => window.close()}
445
+ style={btnStyle}
446
+ title={t('隐藏窗口', 'Hide Window')}
447
+ >
448
+ <EyeOff size={16} />
449
+ </button>
450
+ <button
451
+ onClick={togglePin}
452
+ style={{ ...btnStyle, opacity: pinned ? 1 : 0.5 }}
453
+ title={pinned ? t('取消置顶', 'Unpin') : t('置顶窗口', 'Pin to Top')}
454
+ >
455
+ <Pin size={16} />
456
+ </button>
457
+ <button
458
+ onMouseDown={(e) => {
459
+ e.preventDefault()
460
+ ;(window as any).__clawDragging = true
461
+ let lastX = e.clientX
462
+ let lastY = e.clientY
463
+ const onMove = (ev: MouseEvent) => {
464
+ sceneRef.current?.panCamera(ev.clientX - lastX, ev.clientY - lastY)
465
+ lastX = ev.clientX
466
+ lastY = ev.clientY
467
+ }
468
+ const onUp = () => {
469
+ ;(window as any).__clawDragging = false
470
+ window.removeEventListener('mousemove', onMove)
471
+ window.removeEventListener('mouseup', onUp)
472
+ }
473
+ window.addEventListener('mousemove', onMove)
474
+ window.addEventListener('mouseup', onUp)
475
+ }}
476
+ style={{ ...btnStyle, cursor: 'grab' }}
477
+ title={t('拖动移动人物位置', 'Drag to Move')}
478
+ >
479
+ <Move size={16} />
480
+ </button>
481
+ <button
482
+ onMouseDown={(e) => {
483
+ e.preventDefault()
484
+ ;(window as any).__clawDragging = true
485
+ let lastX = e.clientX
486
+ let lastY = e.clientY
487
+ const onMove = (ev: MouseEvent) => {
488
+ sceneRef.current?.rotateCamera(ev.clientX - lastX, ev.clientY - lastY)
489
+ lastX = ev.clientX
490
+ lastY = ev.clientY
491
+ }
492
+ const onUp = () => {
493
+ ;(window as any).__clawDragging = false
494
+ window.removeEventListener('mousemove', onMove)
495
+ window.removeEventListener('mouseup', onUp)
496
+ }
497
+ window.addEventListener('mousemove', onMove)
498
+ window.addEventListener('mouseup', onUp)
499
+ }}
500
+ style={{ ...btnStyle, cursor: 'grab' }}
501
+ title={t('拖动旋转视角', 'Drag to Rotate')}
502
+ >
503
+ <Rotate3D size={16} />
504
+ </button>
505
+ <button
506
+ onClick={() => {
507
+ if (dancing) {
508
+ sceneRef.current?.reset()
509
+ setDancing(false)
510
+ } else {
511
+ const label = currentDance.startsWith('custom:') && customDancePreset
512
+ ? customDancePreset.label
513
+ : dancePresets[currentDance]?.label ?? currentDance
514
+ if (currentDance.startsWith('custom:') && customDancePreset) {
515
+ sceneRef.current?.playDance(customDancePreset)
516
+ } else {
517
+ sceneRef.current?.playDance(currentDance)
518
+ }
519
+ setDancing(true)
520
+ // Record dance event in session history (no LLM reply)
521
+ fetch(`${FRIEND_API}/session/memo`, {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json' },
524
+ body: JSON.stringify({ text: `[用户邀请你跳了一支舞:${label}]` }),
525
+ }).catch(() => {})
526
+ }
527
+ }}
528
+ style={{
529
+ ...btnStyle,
530
+ ...(dancing ? { background: 'rgba(34, 197, 94, 0.6)' } : {}),
531
+ }}
532
+ title={dancing ? t('停止跳舞', 'Stop Dance') : t('跳舞', 'Dance')}
533
+ >
534
+ <Music size={16} />
535
+ </button>
536
+ </>}
537
+ </div>}
538
+ </div>
539
+ )
540
+ }
friend/frontend/src/api.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { VRMSceneHandle } from './components/VRMScene'
2
+
3
+ /** VersperClaw server base URL */
4
+ export const SERVER_URL = 'http://127.0.0.1:3456'
5
+
6
+ /** Base path for the Friend VRM plugin API */
7
+ export const FRIEND_API = `${SERVER_URL}/plugins/friend`
8
+
9
+ let sceneHandle: VRMSceneHandle | null = null
10
+
11
+ export function bindScene(handle: VRMSceneHandle | null) {
12
+ sceneHandle = handle
13
+ }
14
+
15
+ /**
16
+ * POST to a server API. If the request body contains an `emotion` field,
17
+ * the VRM expression will automatically change to match.
18
+ *
19
+ * Body example:
20
+ * { emotion: "happy", message: "hello", ... }
21
+ *
22
+ * Supported emotions: happy, sad, angry, surprised, think, neutral
23
+ *
24
+ * Optional fields:
25
+ * emotionDuration — ms, auto-reset to neutral after this time
26
+ * emotionIntensity — 0~1, defaults to 1
27
+ */
28
+ export async function postApi<T = any>(url: string, body: Record<string, any>): Promise<T> {
29
+ // Trigger emotion from the request body
30
+ if (body.emotion && sceneHandle) {
31
+ const duration = body.emotionDuration as number | undefined
32
+ const intensity = body.emotionIntensity as number | undefined
33
+ if (duration) {
34
+ sceneHandle.setEmotionWithReset(body.emotion, duration, intensity)
35
+ } else {
36
+ sceneHandle.setEmotion(body.emotion, intensity)
37
+ }
38
+ }
39
+
40
+ const res = await fetch(url, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(body),
44
+ })
45
+
46
+ if (!res.ok) {
47
+ throw new Error(`API error: ${res.status} ${res.statusText}`)
48
+ }
49
+
50
+ return res.json()
51
+ }
friend/frontend/src/assets/lip-sync-profile.json ADDED
@@ -0,0 +1 @@
 
 
1
+ { "jsonPath": "/home/steamvr/projects/wLipSync/www/profile.json", "mfccNum": 12, "mfccDataCount": 12, "melFilterBankChannels": 30, "targetSampleRate": 16000, "sampleCount": 1024, "useStandardization": false, "compareMethod": 2, "mfccs": [{ "name": "A", "mfccCalibrationDataList": [{ "array": [94.40318298339844, 0.32245922088623049, -65.5116195678711, -29.537851333618165, 4.888294219970703, 14.523965835571289, -32.6411247253418, 1.6505765914916993, -9.960077285766602, -5.7025322914123539, -11.886154174804688, -24.35236358642578] }, { "array": [94.40318298339844, 0.32245922088623049, -65.5116195678711, -29.537851333618165, 4.888294219970703, 14.523965835571289, -32.6411247253418, 1.6505765914916993, -9.960077285766602, -5.7025322914123539, -11.886154174804688, -24.35236358642578] }, { "array": [102.16287231445313, -3.3587560653686525, -65.58428192138672, -25.24440574645996, 3.224522590637207, 12.005892753601075, -29.293079376220704, 0.6378564834594727, -10.817683219909668, -1.3263540267944337, -14.543159484863282, -24.169780731201173] }, { "array": [99.35592651367188, -3.681424140930176, -65.20439910888672, -23.45950698852539, 6.205645561218262, 14.96288013458252, -29.882709503173829, 0.6733551025390625, -7.077619552612305, -3.5570802688598635, -14.427347183227539, -23.340003967285158] }, { "array": [99.35592651367188, -3.681424140930176, -65.20439910888672, -23.45950698852539, 6.205645561218262, 14.96288013458252, -29.882709503173829, 0.6733551025390625, -7.077619552612305, -3.5570802688598635, -14.427347183227539, -23.340003967285158] }, { "array": [104.24951171875, -2.8328847885131838, -66.59016418457031, -22.962886810302736, 5.519782066345215, 16.50394058227539, -32.338768005371097, 5.820473670959473, -10.59586238861084, -2.7398462295532228, -12.5281400680542, -24.459365844726564] }, { "array": [104.24951171875, -2.8328847885131838, -66.59016418457031, -22.962886810302736, 5.519782066345215, 16.50394058227539, -32.338768005371097, 5.820473670959473, -10.59586238861084, -2.7398462295532228, -12.5281400680542, -24.459365844726564] }, { "array": [95.58644104003906, -5.775191307067871, -61.220008850097659, -24.658382415771486, 4.4112701416015629, 13.673284530639649, -25.223039627075197, -2.0546646118164064, -6.887641906738281, -5.683987617492676, -11.20918083190918, -23.215322494506837] }, { "array": [98.24864196777344, -3.8367862701416017, -62.34006118774414, -24.563793182373048, 4.608433723449707, 16.228965759277345, -28.992279052734376, 2.1237001419067385, -9.07174015045166, -4.581008434295654, -10.662440299987793, -26.19581413269043] }, { "array": [102.7921142578125, -4.580304145812988, -62.531837463378909, -26.292770385742189, 7.911410331726074, 17.136384963989259, -31.118263244628908, 5.196089744567871, -10.010396957397461, -0.8527965545654297, -12.346561431884766, -23.580944061279298] }, { "array": [102.12345123291016, -1.5254135131835938, -62.21220397949219, -26.728734970092775, 10.62057876586914, 16.918357849121095, -28.815664291381837, 3.6714258193969728, -9.673786163330079, -0.7385025024414063, -9.717185020446778, -27.09702491760254] }, { "array": [102.12345123291016, -1.5254135131835938, -62.21220397949219, -26.728734970092775, 10.62057876586914, 16.918357849121095, -28.815664291381837, 3.6714258193969728, -9.673786163330079, -0.7385025024414063, -9.717185020446778, -27.09702491760254] }] }, { "name": "I", "mfccCalibrationDataList": [{ "array": [14.441835403442383, 52.400115966796878, 57.377838134765628, -33.70046615600586, -12.751934051513672, -15.709930419921875, -41.381065368652347, -12.190519332885743, -2.863154411315918, -8.727733612060547, 2.6656012535095217, 1.4855976104736329] }, { "array": [14.441835403442383, 52.400115966796878, 57.377838134765628, -33.70046615600586, -12.751934051513672, -15.709930419921875, -41.381065368652347, -12.190519332885743, -2.863154411315918, -8.727733612060547, 2.6656012535095217, 1.4855976104736329] }, { "array": [15.294279098510743, 50.07628631591797, 57.262847900390628, -31.748844146728517, -13.642471313476563, -13.48408031463623, -41.535011291503909, -16.863862991333009, -1.739903450012207, -9.32723331451416, 8.31618881225586, -1.779850959777832] }, { "array": [15.685098648071289, 49.68647766113281, 58.447113037109378, -32.513519287109378, -16.664287567138673, -13.78364372253418, -40.48309326171875, -16.04582405090332, -3.5356569290161135, -8.654275894165039, 10.645575523376465, -2.556441307067871] }, { "array": [15.685098648071289, 49.68647766113281, 58.447113037109378, -32.513519287109378, -16.664287567138673, -13.78364372253418, -40.48309326171875, -16.04582405090332, -3.5356569290161135, -8.654275894165039, 10.645575523376465, -2.556441307067871] }, { "array": [15.685098648071289, 49.68647766113281, 58.447113037109378, -32.513519287109378, -16.664287567138673, -13.78364372253418, -40.48309326171875, -16.04582405090332, -3.5356569290161135, -8.654275894165039, 10.645575523376465, -2.556441307067871] }, { "array": [15.225826263427735, 43.872196197509769, 52.844512939453128, -32.51786804199219, -17.806241989135743, -10.609650611877442, -40.13084411621094, -11.58648681640625, -5.082568168640137, -14.396997451782227, 6.896979331970215, -0.785430908203125] }, { "array": [15.571629524230957, 48.634883880615237, 59.5339469909668, -34.59955596923828, -21.959871292114259, -10.298498153686524, -39.11286926269531, -11.998537063598633, -8.433327674865723, -10.80599594116211, 8.789299011230469, -1.884697437286377] }, { "array": [15.571629524230957, 48.634883880615237, 59.5339469909668, -34.59955596923828, -21.959871292114259, -10.298498153686524, -39.11286926269531, -11.998537063598633, -8.433327674865723, -10.80599594116211, 8.789299011230469, -1.884697437286377] }, { "array": [15.571629524230957, 48.634883880615237, 59.5339469909668, -34.59955596923828, -21.959871292114259, -10.298498153686524, -39.11286926269531, -11.998537063598633, -8.433327674865723, -10.80599594116211, 8.789299011230469, -1.884697437286377] }, { "array": [19.23290252685547, 47.433998107910159, 54.90937423706055, -33.7783203125, -13.836353302001954, -5.141571044921875, -39.34584045410156, -13.409493446350098, -4.945652008056641, -12.960502624511719, 12.210061073303223, 0.5807018280029297] }, { "array": [19.124774932861329, 46.46723937988281, 53.41281509399414, -34.65093994140625, -18.181049346923829, -7.733134746551514, -45.67931365966797, -10.64135456085205, -2.624391555786133, -15.708955764770508, 7.3649187088012699, -5.627689361572266] }] }, { "name": "U", "mfccCalibrationDataList": [{ "array": [83.38372802734375, 42.39790725708008, 27.812450408935548, 10.696150779724121, -13.612553596496582, -32.487091064453128, -35.2574348449707, -6.425739288330078, -4.214997291564941, -6.896385669708252, -3.49631404876709, 4.997060775756836] }, { "array": [103.65653228759766, 38.661563873291019, 30.985050201416017, 17.432451248168947, -14.383820533752442, -39.810001373291019, -39.63761901855469, 1.2333955764770508, -4.217883110046387, -3.005303382873535, -6.272947311401367, 4.751875877380371] }, { "array": [103.44242095947266, 45.238216400146487, 27.622669219970704, 18.682138442993165, -16.854982376098634, -39.85029602050781, -34.15940856933594, -2.7482595443725588, -3.7410573959350588, -1.0625238418579102, -4.215768814086914, 6.514510154724121] }, { "array": [103.44242095947266, 45.238216400146487, 27.622669219970704, 18.682138442993165, -16.854982376098634, -39.85029602050781, -34.15940856933594, -2.7482595443725588, -3.7410573959350588, -1.0625238418579102, -4.215768814086914, 6.514510154724121] }, { "array": [99.99739837646485, 39.48998260498047, 26.057384490966798, 23.26814079284668, -16.25522804260254, -38.15496063232422, -35.70051574707031, -1.2821111679077149, -2.3941946029663088, -0.15543842315673829, -4.757769584655762, 2.198577880859375] }, { "array": [99.99739837646485, 39.48998260498047, 26.057384490966798, 23.26814079284668, -16.25522804260254, -38.15496063232422, -35.70051574707031, -1.2821111679077149, -2.3941946029663088, -0.15543842315673829, -4.757769584655762, 2.198577880859375] }, { "array": [107.09538269042969, 37.704010009765628, 17.795482635498048, 21.882326126098634, -14.739266395568848, -36.407527923583987, -37.95854949951172, -1.4393510818481446, -1.9593324661254883, -0.7294750213623047, -7.93386173248291, 3.9560585021972658] }, { "array": [103.5069351196289, 35.08988952636719, 23.21630859375, 23.947580337524415, -14.157055854797364, -38.546836853027347, -39.75208282470703, 0.516876220703125, -2.715259552001953, -4.0768208503723148, -4.716378211975098, 4.662134170532227] }, { "array": [103.5069351196289, 35.08988952636719, 23.21630859375, 23.947580337524415, -14.157055854797364, -38.546836853027347, -39.75208282470703, 0.516876220703125, -2.715259552001953, -4.0768208503723148, -4.716378211975098, 4.662134170532227] }, { "array": [103.5069351196289, 35.08988952636719, 23.21630859375, 23.947580337524415, -14.157055854797364, -38.546836853027347, -39.75208282470703, 0.516876220703125, -2.715259552001953, -4.0768208503723148, -4.716378211975098, 4.662134170532227] }, { "array": [98.43344116210938, 35.42580032348633, 29.2958984375, 24.73729133605957, -15.485936164855957, -44.676483154296878, -39.978858947753909, 0.5548343658447266, -2.2034664154052736, -3.485844612121582, -7.421210289001465, 6.30616569519043] }, { "array": [94.0390625, 36.81925582885742, 24.73573875427246, 22.579418182373048, -14.354126930236817, -37.92849349975586, -44.69046401977539, 0.7474861145019531, -3.3195743560791017, -3.9850082397460939, -5.991059303283691, 6.134122848510742] }] }, { "name": "E", "mfccCalibrationDataList": [{ "array": [60.52040481567383, 14.444153785705567, 50.91899108886719, 6.730878829956055, -58.12107467651367, -16.403745651245118, -25.244909286499025, 5.399906158447266, -7.63681697845459, -2.4964828491210939, 7.271292209625244, 1.7322711944580079] }, { "array": [59.327247619628909, 14.82846450805664, 51.402244567871097, 5.413976669311523, -55.603240966796878, -15.348665237426758, -25.923606872558595, 3.0006580352783205, -4.183259963989258, -3.4587841033935549, 7.941498756408691, 3.4499120712280275] }, { "array": [59.327247619628909, 14.82846450805664, 51.402244567871097, 5.413976669311523, -55.603240966796878, -15.348665237426758, -25.923606872558595, 3.0006580352783205, -4.183259963989258, -3.4587841033935549, 7.941498756408691, 3.4499120712280275] }, { "array": [64.61061096191406, 7.8438310623168949, 54.753726959228519, 11.154451370239258, -62.99680709838867, -10.397377967834473, -36.124359130859378, 12.57413387298584, -6.086113452911377, -3.032306671142578, 10.453157424926758, -0.00012826919555664063] }, { "array": [59.8906135559082, 13.646936416625977, 53.14240646362305, 11.346290588378907, -60.17724609375, -15.942718505859375, -29.547088623046876, 8.241331100463868, -6.8904523849487309, -3.6554131507873537, 14.714229583740235, -2.811859607696533] }, { "array": [53.77520751953125, 6.071747779846191, 47.870723724365237, 5.943275451660156, -51.11442184448242, -16.625276565551759, -24.842336654663087, 4.076478004455566, -8.835965156555176, -4.306196689605713, 13.907751083374024, -0.6555676460266113] }, { "array": [53.77520751953125, 6.071747779846191, 47.870723724365237, 5.943275451660156, -51.11442184448242, -16.625276565551759, -24.842336654663087, 4.076478004455566, -8.835965156555176, -4.306196689605713, 13.907751083374024, -0.6555676460266113] }, { "array": [53.77520751953125, 6.071747779846191, 47.870723724365237, 5.943275451660156, -51.11442184448242, -16.625276565551759, -24.842336654663087, 4.076478004455566, -8.835965156555176, -4.306196689605713, 13.907751083374024, -0.6555676460266113] }, { "array": [62.81553649902344, 6.203365325927734, 48.45057678222656, 8.571174621582032, -53.907508850097659, -16.376169204711915, -25.989578247070314, 5.736949920654297, -8.150140762329102, -5.895424842834473, 13.745902061462403, -4.22935676574707] }, { "array": [53.43303680419922, 7.019550323486328, 43.32084655761719, 7.639513969421387, -49.81471633911133, -18.708377838134767, -21.690540313720704, 0.34458446502685549, -8.689970970153809, -3.96992826461792, 11.29841423034668, -4.165286540985107] }, { "array": [56.29218673706055, 7.115049362182617, 47.741546630859378, 4.102975845336914, -50.46143341064453, -18.235626220703126, -22.557659149169923, 6.925202369689941, -13.170380592346192, -1.0300326347351075, 8.813325881958008, -3.2347850799560549] }, { "array": [56.29218673706055, 7.115049362182617, 47.741546630859378, 4.102975845336914, -50.46143341064453, -18.235626220703126, -22.557659149169923, 6.925202369689941, -13.170380592346192, -1.0300326347351075, 8.813325881958008, -3.2347850799560549] }] }, { "name": "O", "mfccCalibrationDataList": [{ "array": [108.12348937988281, 57.48288345336914, 0.23154354095458985, -31.144771575927736, -38.093109130859378, -18.33026885986328, -11.250101089477539, -0.7636222839355469, -1.371236801147461, -9.181392669677735, -1.6202507019042969, -7.501105308532715] }, { "array": [108.12348937988281, 57.48288345336914, 0.23154354095458985, -31.144771575927736, -38.093109130859378, -18.33026885986328, -11.250101089477539, -0.7636222839355469, -1.371236801147461, -9.181392669677735, -1.6202507019042969, -7.501105308532715] }, { "array": [120.48326873779297, 61.383270263671878, -2.6785545349121095, -32.3900146484375, -40.94635772705078, -12.681024551391602, -10.979912757873536, -0.7160100936889648, -2.9078426361083986, -12.300739288330079, 1.8719825744628907, -6.2853875160217289] }, { "array": [109.58128356933594, 58.46321105957031, 0.1479501724243164, -31.080230712890626, -34.73053741455078, -17.423336029052736, -10.221624374389649, 0.3863086700439453, -1.2579708099365235, -7.037452220916748, -2.894855499267578, -7.0971550941467289] }, { "array": [109.58128356933594, 58.46321105957031, 0.1479501724243164, -31.080230712890626, -34.73053741455078, -17.423336029052736, -10.221624374389649, 0.3863086700439453, -1.2579708099365235, -7.037452220916748, -2.894855499267578, -7.0971550941467289] }, { "array": [116.19681549072266, 60.3573112487793, 0.07350921630859375, -32.384727478027347, -34.47290802001953, -16.373615264892579, -14.695085525512696, 0.3102083206176758, 0.28844451904296877, -8.948881149291993, -3.104994773864746, -9.900266647338868] }, { "array": [116.19681549072266, 60.3573112487793, 0.07350921630859375, -32.384727478027347, -34.47290802001953, -16.373615264892579, -14.695085525512696, 0.3102083206176758, 0.28844451904296877, -8.948881149291993, -3.104994773864746, -9.900266647338868] }, { "array": [98.63448333740235, 48.661781311035159, 2.394181251525879, -28.785797119140626, -31.548860549926759, -18.37759017944336, -14.998208999633789, -1.8050260543823243, -2.018402099609375, -4.584748268127441, -5.160560607910156, -7.968695163726807] }, { "array": [124.25032043457031, 59.27610397338867, 2.7454710006713869, -36.72577667236328, -38.65552520751953, -3.116687774658203, -24.24558448791504, 0.5085678100585938, 2.3633852005004885, -10.51361083984375, 1.7447805404663087, -13.22685432434082] }, { "array": [87.89213562011719, 45.08750534057617, 4.292821884155273, -26.482845306396486, -30.386096954345704, -20.410654067993165, -11.817208290100098, -3.1270408630371095, -1.1370172500610352, -6.159217357635498, -3.454045295715332, -5.7265400886535648] }, { "array": [87.89213562011719, 45.08750534057617, 4.292821884155273, -26.482845306396486, -30.386096954345704, -20.410654067993165, -11.817208290100098, -3.1270408630371095, -1.1370172500610352, -6.159217357635498, -3.454045295715332, -5.7265400886535648] }, { "array": [87.89213562011719, 45.08750534057617, 4.292821884155273, -26.482845306396486, -30.386096954345704, -20.410654067993165, -11.817208290100098, -3.1270408630371095, -1.1370172500610352, -6.159217357635498, -3.454045295715332, -5.7265400886535648] }] }, { "name": "S", "mfccCalibrationDataList": [{ "array": [-94.29214477539063, 9.299236297607422, -21.6169376373291, 4.123956203460693, 1.3645498752593995, -2.339733839035034, 12.92388916015625, -7.490560531616211, 10.520170211791993, -5.4832611083984379, 2.1621110439300539, 2.6961774826049806] }, { "array": [-94.29214477539063, 9.299236297607422, -21.6169376373291, 4.123956203460693, 1.3645498752593995, -2.339733839035034, 12.92388916015625, -7.490560531616211, 10.520170211791993, -5.4832611083984379, 2.1621110439300539, 2.6961774826049806] }, { "array": [-92.13811492919922, 8.712703704833985, -17.194181442260743, 4.387561798095703, 2.288078784942627, -9.562786102294922, 9.854814529418946, -7.843216896057129, 8.969221115112305, -7.9670305252075199, 0.38271141052246096, 2.731431007385254] }, { "array": [-92.13811492919922, 8.712703704833985, -17.194181442260743, 4.387561798095703, 2.288078784942627, -9.562786102294922, 9.854814529418946, -7.843216896057129, 8.969221115112305, -7.9670305252075199, 0.38271141052246096, 2.731431007385254] }, { "array": [-90.87933349609375, 12.622742652893067, -12.25172233581543, 6.775156497955322, 6.892632007598877, -4.708589553833008, 10.558273315429688, -10.194192886352539, 10.907587051391602, 0.7259445190429688, 6.631969451904297, 1.2803831100463868] }, { "array": [-90.87933349609375, 12.622742652893067, -12.25172233581543, 6.775156497955322, 6.892632007598877, -4.708589553833008, 10.558273315429688, -10.194192886352539, 10.907587051391602, 0.7259445190429688, 6.631969451904297, 1.2803831100463868] }, { "array": [-94.86066436767578, 18.40726089477539, -5.981902599334717, 7.446142196655273, 11.998884201049805, 1.1316719055175782, 5.726372718811035, -9.411809921264649, 9.966836929321289, 0.6692547798156738, 3.0949947834014894, -0.5439543724060059] }, { "array": [-94.86066436767578, 18.40726089477539, -5.981902599334717, 7.446142196655273, 11.998884201049805, 1.1316719055175782, 5.726372718811035, -9.411809921264649, 9.966836929321289, 0.6692547798156738, 3.0949947834014894, -0.5439543724060059] }, { "array": [-94.86066436767578, 18.40726089477539, -5.981902599334717, 7.446142196655273, 11.998884201049805, 1.1316719055175782, 5.726372718811035, -9.411809921264649, 9.966836929321289, 0.6692547798156738, 3.0949947834014894, -0.5439543724060059] }, { "array": [-100.9681396484375, 15.002283096313477, -4.994745254516602, 0.22259855270385743, -0.15606117248535157, -1.8661277294158936, 1.5652005672454835, -13.30648422241211, 12.554527282714844, -2.990779399871826, 3.5510034561157228, 5.119507312774658] }, { "array": [-100.9681396484375, 15.002283096313477, -4.994745254516602, 0.22259855270385743, -0.15606117248535157, -1.8661277294158936, 1.5652005672454835, -13.30648422241211, 12.554527282714844, -2.990779399871826, 3.5510034561157228, 5.119507312774658] }, { "array": [-101.28047943115235, 14.962509155273438, -10.988410949707032, 3.6384878158569338, -1.4698257446289063, -4.758091449737549, -1.3547701835632325, -12.941855430603028, 3.3519961833953859, -5.5131611824035648, 9.386914253234864, 3.8310816287994386] }] }, { "name": "A", "mfccCalibrationDataList": [{ "array": [4.20286750793457, -73.493896484375, -24.746726989746095, -41.51460266113281, 36.48657989501953, -18.2531795501709, -42.99116516113281, 25.612823486328126, -18.336681365966798, -15.366691589355469, -4.867555618286133, -8.545194625854493] }, { "array": [6.645953178405762, -70.99688720703125, -23.49920082092285, -40.70307922363281, 35.09113311767578, -19.63579750061035, -41.851219177246097, 26.548370361328126, -20.361244201660158, -15.091900825500489, -5.332237243652344, -7.199653625488281] }, { "array": [6.645953178405762, -70.99688720703125, -23.49920082092285, -40.70307922363281, 35.09113311767578, -19.63579750061035, -41.851219177246097, 26.548370361328126, -20.361244201660158, -15.091900825500489, -5.332237243652344, -7.199653625488281] }, { "array": [3.4860363006591799, -73.30689239501953, -21.244325637817384, -38.725765228271487, 41.22803497314453, -15.975458145141602, -42.09079360961914, 27.367385864257814, -19.52243995666504, -16.397735595703126, -3.7795209884643556, -4.958502292633057] }, { "array": [0.09921550750732422, -70.22235107421875, -20.888980865478517, -33.53620910644531, 48.01523208618164, -13.365336418151856, -42.88488006591797, 26.451934814453126, -13.952343940734864, -16.65743064880371, -1.894343376159668, -1.8812918663024903] }, { "array": [0.09921550750732422, -70.22235107421875, -20.888980865478517, -33.53620910644531, 48.01523208618164, -13.365336418151856, -42.88488006591797, 26.451934814453126, -13.952343940734864, -16.65743064880371, -1.894343376159668, -1.8812918663024903] }, { "array": [0.09921550750732422, -70.22235107421875, -20.888980865478517, -33.53620910644531, 48.01523208618164, -13.365336418151856, -42.88488006591797, 26.451934814453126, -13.952343940734864, -16.65743064880371, -1.894343376159668, -1.8812918663024903] }, { "array": [-4.117016792297363, -72.00682830810547, -19.490331649780275, -34.48163986206055, 51.383995056152347, -9.989368438720704, -41.8690185546875, 23.824867248535158, -11.14547061920166, -14.500547409057618, -2.504335403442383, 1.0616645812988282] }, { "array": [-3.0772790908813478, -77.95940399169922, -21.169992446899415, -43.19057846069336, 47.594181060791019, -14.239681243896485, -43.05297088623047, 23.796558380126954, -16.033828735351564, -14.308491706848145, -4.90071964263916, -0.6597251892089844] }, { "array": [-3.0772790908813478, -77.95940399169922, -21.169992446899415, -43.19057846069336, 47.594181060791019, -14.239681243896485, -43.05297088623047, 23.796558380126954, -16.033828735351564, -14.308491706848145, -4.90071964263916, -0.6597251892089844] }, { "array": [0.27674388885498049, -74.16876220703125, -19.241043090820314, -43.69765853881836, 45.94886779785156, -15.777923583984375, -40.226318359375, 25.209468841552736, -19.91909408569336, -14.123311042785645, -6.749327659606934, -2.186051368713379] }, { "array": [1.313084602355957, -70.17343139648438, -20.149150848388673, -40.1507568359375, 43.281288146972659, -17.598236083984376, -39.989742279052737, 19.475574493408204, -18.73434066772461, -15.377893447875977, -3.6761083602905275, -2.3720903396606447] }] }, { "name": "I", "mfccCalibrationDataList": [{ "array": [-43.23860549926758, 15.659211158752442, 48.96119689941406, -84.8009033203125, -2.832998275756836, -25.101383209228517, -19.283388137817384, -1.3310365676879883, -3.9067935943603517, -5.5547566413879398, -24.206161499023439, 19.690078735351564] }, { "array": [-43.23860549926758, 15.659211158752442, 48.96119689941406, -84.8009033203125, -2.832998275756836, -25.101383209228517, -19.283388137817384, -1.3310365676879883, -3.9067935943603517, -5.5547566413879398, -24.206161499023439, 19.690078735351564] }, { "array": [-43.23860549926758, 15.659211158752442, 48.96119689941406, -84.8009033203125, -2.832998275756836, -25.101383209228517, -19.283388137817384, -1.3310365676879883, -3.9067935943603517, -5.5547566413879398, -24.206161499023439, 19.690078735351564] }, { "array": [-35.28649139404297, 11.94938850402832, 46.56689453125, -80.19161987304688, -5.332358360290527, -16.747013092041017, -17.014617919921876, -6.106320381164551, -3.5759763717651369, -11.297148704528809, -19.73563575744629, 19.95285415649414] }, { "array": [-35.28649139404297, 11.94938850402832, 46.56689453125, -80.19161987304688, -5.332358360290527, -16.747013092041017, -17.014617919921876, -6.106320381164551, -3.5759763717651369, -11.297148704528809, -19.73563575744629, 19.95285415649414] }, { "array": [-33.85117721557617, 12.748700141906739, 48.141944885253909, -75.80270385742188, -0.9702749252319336, -14.077031135559082, -15.911153793334961, -4.433165073394775, -4.00740909576416, -9.756240844726563, -20.910476684570314, 18.42697525024414] }, { "array": [-29.72378921508789, 13.039468765258789, 47.7448616027832, -74.11089324951172, 0.8275318145751953, -16.012189865112306, -17.36796760559082, -1.0376081466674805, -4.8292741775512699, -6.667880058288574, -23.82168960571289, 16.032718658447267] }, { "array": [-29.259418487548829, 11.963765144348145, 41.54622268676758, -75.67298889160156, -2.0329294204711916, -20.586360931396486, -21.125045776367189, 0.5504961013793945, -5.419882774353027, -6.314876556396484, -25.0130615234375, 14.262750625610352] }, { "array": [-29.259418487548829, 11.963765144348145, 41.54622268676758, -75.67298889160156, -2.0329294204711916, -20.586360931396486, -21.125045776367189, 0.5504961013793945, -5.419882774353027, -6.314876556396484, -25.0130615234375, 14.262750625610352] }, { "array": [-28.383831024169923, 13.103409767150879, 39.94292449951172, -81.08953857421875, -4.134577751159668, -21.590072631835939, -23.217021942138673, -0.3798789978027344, -5.0724334716796879, -5.94474983215332, -26.63843536376953, 16.777332305908204] }, { "array": [-30.048954010009767, 16.332015991210939, 44.49327850341797, -81.91828155517578, -4.171995162963867, -19.618621826171876, -20.534595489501954, -0.9673957824707031, -3.2188777923583986, -6.572293758392334, -26.59181785583496, 18.48187255859375] }, { "array": [-30.987979888916017, 14.425168991088868, 48.951114654541019, -82.33119201660156, -4.302616119384766, -17.642169952392579, -19.921981811523439, -1.7414522171020508, -1.7319145202636719, -6.870545387268066, -22.85688591003418, 19.475980758666993] }] }, { "name": "U", "mfccCalibrationDataList": [{ "array": [35.53300476074219, -11.371288299560547, 16.699298858642579, -39.32943344116211, 2.8931827545166017, -39.35669708251953, -22.81580924987793, -8.255973815917969, -7.3601884841918949, 3.866161346435547, -36.18340301513672, 0.44779300689697268] }, { "array": [34.92810821533203, -12.66100025177002, 19.551185607910158, -38.589942932128909, 1.5609407424926758, -42.781375885009769, -22.405025482177736, -4.380008220672607, -9.27183723449707, 5.952349662780762, -38.181243896484378, 1.4162702560424805] }, { "array": [34.508079528808597, -10.0599946975708, 18.88445281982422, -34.42189407348633, -0.8881950378417969, -42.52379608154297, -20.37147331237793, -0.6865062713623047, -8.89057731628418, 6.381434440612793, -34.816715240478519, 3.530543327331543] }, { "array": [34.508079528808597, -10.0599946975708, 18.88445281982422, -34.42189407348633, -0.8881950378417969, -42.52379608154297, -20.37147331237793, -0.6865062713623047, -8.89057731628418, 6.381434440612793, -34.816715240478519, 3.530543327331543] }, { "array": [37.634185791015628, -14.223724365234375, 21.262420654296876, -39.50825119018555, 0.31142520904541018, -43.201324462890628, -19.136680603027345, -0.7461652755737305, -8.253379821777344, 9.12716293334961, -33.483253479003909, 9.340311050415039] }, { "array": [38.28554916381836, -15.814170837402344, 19.503570556640626, -40.19694900512695, -0.7106151580810547, -45.373783111572269, -20.631587982177736, -2.2332963943481447, -6.609874725341797, 8.43967342376709, -33.560943603515628, 8.549976348876954] }, { "array": [38.28554916381836, -15.814170837402344, 19.503570556640626, -40.19694900512695, -0.7106151580810547, -45.373783111572269, -20.631587982177736, -2.2332963943481447, -6.609874725341797, 8.43967342376709, -33.560943603515628, 8.549976348876954] }, { "array": [36.15192413330078, -19.43944549560547, 19.22710609436035, -40.229862213134769, -1.7119722366333008, -44.58899688720703, -22.39651870727539, -4.6873579025268559, -5.184035301208496, 5.601207733154297, -35.313777923583987, 6.856324195861816] }, { "array": [33.697601318359378, -19.53946304321289, 18.151920318603517, -38.29360580444336, -1.9536991119384766, -39.185218811035159, -21.181903839111329, -8.092260360717774, -6.441320419311523, 3.8472461700439455, -32.4590950012207, 8.79825210571289] }, { "array": [31.768585205078126, -17.137840270996095, 15.726847648620606, -36.897605895996097, -3.8221397399902345, -35.492652893066409, -19.920631408691408, -10.421395301818848, -7.471479415893555, 1.7216854095458985, -25.726573944091798, 12.090950965881348] }, { "array": [31.768585205078126, -17.137840270996095, 15.726847648620606, -36.897605895996097, -3.8221397399902345, -35.492652893066409, -19.920631408691408, -10.421395301818848, -7.471479415893555, 1.7216854095458985, -25.726573944091798, 12.090950965881348] }, { "array": [32.6759033203125, -15.928326606750489, 16.0853271484375, -34.126686096191409, -6.689325332641602, -33.79350662231445, -19.242847442626954, -10.59890079498291, -10.992877006530762, -2.9957642555236818, -23.329811096191408, 14.231583595275879] }] }, { "name": "U", "mfccCalibrationDataList": [{ "array": [50.45172882080078, -7.723395347595215, 32.294891357421878, -11.293773651123047, -22.777332305908204, -36.17817687988281, -17.044910430908204, -3.7672786712646486, -14.233147621154786, -17.250513076782228, -19.240345001220704, -6.971443176269531] }, { "array": [53.59954071044922, -9.874463081359864, 35.237457275390628, -11.929043769836426, -23.915904998779298, -38.07780838012695, -15.71041202545166, -6.737283706665039, -12.06786060333252, -14.436643600463868, -20.401880264282228, -7.594654560089111] }, { "array": [57.36424255371094, -14.57245922088623, 35.10681915283203, -13.044787406921387, -24.196090698242189, -36.5896110534668, -16.161855697631837, -10.228910446166993, -10.43470287322998, -10.997936248779297, -20.92641830444336, -7.631929397583008] }, { "array": [57.65166473388672, -16.92430305480957, 33.022151947021487, -12.667181968688965, -21.69562530517578, -32.90369415283203, -14.275350570678711, -10.332601547241211, -10.544659614562989, -6.801647186279297, -22.47809410095215, -8.94782543182373] }, { "array": [49.60702133178711, -9.94367504119873, 25.04156494140625, -9.98995304107666, -21.814006805419923, -28.99759292602539, -16.792327880859376, -9.5930814743042, -11.523345947265625, -7.568509578704834, -20.688356399536134, -10.5545015335083] }, { "array": [53.433990478515628, -10.947863578796387, 30.426055908203126, -10.506410598754883, -22.997278213500978, -31.270254135131837, -16.90880584716797, -9.606002807617188, -10.785472869873047, -9.04238510131836, -22.82281494140625, -12.42250919342041] }, { "array": [57.11727523803711, -14.77857780456543, 33.16322326660156, -13.437588691711426, -25.450626373291017, -33.84945297241211, -17.6593017578125, -11.152002334594727, -12.203851699829102, -9.72322940826416, -26.653217315673829, -12.098143577575684] }, { "array": [58.65357208251953, -15.040183067321778, 32.92494583129883, -12.598053932189942, -24.03311538696289, -33.4146728515625, -15.565327644348145, -11.081777572631836, -10.47522258758545, -9.327695846557618, -28.43020248413086, -11.224303245544434] }, { "array": [53.888484954833987, -13.592279434204102, 31.6711368560791, -9.440587043762207, -23.316177368164064, -35.3663444519043, -19.26239776611328, -13.472862243652344, -11.312352180480957, -7.2335309982299809, -25.466888427734376, -8.924440383911133] }, { "array": [54.30769348144531, -13.315286636352539, 26.960655212402345, -11.682543754577637, -23.105655670166017, -32.507049560546878, -17.883333206176759, -10.961587905883789, -11.377249717712403, -7.610130786895752, -24.951541900634767, -9.38066577911377] }, { "array": [57.15829849243164, -13.771659851074219, 31.29568099975586, -9.493865966796875, -23.708837509155275, -33.85169982910156, -19.99985122680664, -12.555244445800782, -14.447962760925293, -7.608822822570801, -23.899951934814454, -6.755941867828369] }, { "array": [47.781307220458987, -14.279045104980469, 23.228328704833986, -14.190330505371094, -23.399112701416017, -34.95072555541992, -21.406070709228517, -10.15461254119873, -14.686234474182129, -9.932022094726563, -22.141719818115236, -8.354757308959961] }] }, { "name": "E", "mfccCalibrationDataList": [{ "array": [7.131360054016113, -36.049346923828128, 45.31159210205078, -45.00178527832031, -15.53347110748291, 1.3071203231811524, 2.57974910736084, -1.8762083053588868, -25.02313995361328, -22.275257110595704, -16.383546829223634, -17.868574142456056] }, { "array": [7.131360054016113, -36.049346923828128, 45.31159210205078, -45.00178527832031, -15.53347110748291, 1.3071203231811524, 2.57974910736084, -1.8762083053588868, -25.02313995361328, -22.275257110595704, -16.383546829223634, -17.868574142456056] }, { "array": [11.067873001098633, -36.16781234741211, 45.893943786621097, -43.60368347167969, -15.017866134643555, -1.2796411514282227, 2.090773582458496, -2.00726318359375, -22.139572143554689, -22.111957550048829, -16.975831985473634, -17.187711715698243] }, { "array": [9.000146865844727, -36.58183288574219, 46.39983367919922, -45.5433349609375, -15.245804786682129, -2.223395347595215, 4.547385215759277, -1.7552833557128907, -24.691539764404298, -22.836109161376954, -15.247169494628907, -15.090204238891602] }, { "array": [6.99749755859375, -37.912235260009769, 46.3832893371582, -47.186683654785159, -13.492773056030274, -2.4662675857543947, 5.109303951263428, -1.4368247985839844, -25.965919494628908, -23.327587127685548, -14.712173461914063, -15.369802474975586] }, { "array": [8.53531551361084, -38.04619598388672, 47.71860885620117, -47.188323974609378, -12.696022987365723, 0.9119815826416016, 5.812184810638428, 1.9683570861816407, -28.441104888916017, -21.203857421875, -16.284610748291017, -15.28965950012207] }, { "array": [8.53531551361084, -38.04619598388672, 47.71860885620117, -47.188323974609378, -12.696022987365723, 0.9119815826416016, 5.812184810638428, 1.9683570861816407, -28.441104888916017, -21.203857421875, -16.284610748291017, -15.28965950012207] }, { "array": [10.728363037109375, -37.86867141723633, 48.31739044189453, -47.47754669189453, -12.409613609313965, -0.4410533905029297, 5.663397789001465, 3.449146270751953, -27.80557632446289, -20.967662811279298, -15.19067096710205, -14.877350807189942] }, { "array": [10.065123558044434, -36.660072326660159, 44.76460266113281, -47.56492233276367, -12.322249412536621, -2.3014774322509767, 3.445328712463379, 3.1899805068969728, -25.734312057495118, -21.715232849121095, -13.482653617858887, -17.041053771972658] }, { "array": [8.782588958740235, -36.92534255981445, 44.4034309387207, -46.98899841308594, -8.84085464477539, -3.2711610794067385, 2.8354501724243166, 3.1592531204223635, -24.939672470092775, -21.54747772216797, -15.242938041687012, -17.53165054321289] }, { "array": [6.508709907531738, -36.80110168457031, 42.792266845703128, -46.29795837402344, -6.919498443603516, -3.788252830505371, 2.5329980850219728, 4.23353385925293, -24.539836883544923, -21.791210174560548, -16.75529670715332, -18.159324645996095] }, { "array": [6.508709907531738, -36.80110168457031, 42.792266845703128, -46.29795837402344, -6.919498443603516, -3.788252830505371, 2.5329980850219728, 4.23353385925293, -24.539836883544923, -21.791210174560548, -16.75529670715332, -18.159324645996095] }] }, { "name": "O", "mfccCalibrationDataList": [{ "array": [53.443660736083987, -12.53360652923584, -26.871780395507814, -71.98885345458985, -5.861575126647949, -1.7120800018310547, -32.90825653076172, 23.514209747314454, -10.125606536865235, -11.977684020996094, 9.883563041687012, -5.261895179748535] }, { "array": [55.69048309326172, -10.316678047180176, -26.289718627929689, -74.05561828613281, -4.348355293273926, -1.1102313995361329, -32.498268127441409, 22.016807556152345, -11.227481842041016, -11.753466606140137, 10.583023071289063, -3.583785057067871] }, { "array": [55.69048309326172, -10.316678047180176, -26.289718627929689, -74.05561828613281, -4.348355293273926, -1.1102313995361329, -32.498268127441409, 22.016807556152345, -11.227481842041016, -11.753466606140137, 10.583023071289063, -3.583785057067871] }, { "array": [56.333030700683597, -9.761429786682129, -24.525028228759767, -75.67504119873047, -2.9411144256591799, -1.0509262084960938, -32.58983612060547, 20.397789001464845, -13.52730941772461, -10.221673965454102, 8.806441307067871, -6.1672868728637699] }, { "array": [58.425559997558597, -13.048576354980469, -26.326568603515626, -77.33265686035156, 0.8971290588378906, -0.13758087158203126, -34.79779052734375, 22.524978637695314, -15.138383865356446, -9.027335166931153, 10.94324779510498, -6.867808818817139] }, { "array": [57.16611099243164, -17.380069732666017, -26.70465087890625, -76.53448486328125, 3.203751564025879, 1.6217775344848633, -36.67759323120117, 24.14405059814453, -12.522785186767579, -8.60572338104248, 13.969680786132813, -4.909186840057373] }, { "array": [55.17543029785156, -22.491680145263673, -25.568838119506837, -74.10460662841797, 2.1666202545166017, 4.1397705078125, -37.21670913696289, 21.997055053710939, -11.680967330932618, -8.396781921386719, 13.454421997070313, -3.452665328979492] }, { "array": [55.18680953979492, -23.176528930664064, -23.64011573791504, -69.64533233642578, 1.9893865585327149, 7.129931449890137, -35.871803283691409, 19.410160064697267, -13.193324089050293, -8.643393516540528, 10.173726081848145, -3.2846717834472658] }, { "array": [55.18680953979492, -23.176528930664064, -23.64011573791504, -69.64533233642578, 1.9893865585327149, 7.129931449890137, -35.871803283691409, 19.410160064697267, -13.193324089050293, -8.643393516540528, 10.173726081848145, -3.2846717834472658] }, { "array": [52.92599105834961, -22.969005584716798, -25.62136459350586, -65.79485321044922, 0.01132965087890625, 5.457650184631348, -33.96955871582031, 16.389381408691408, -14.026248931884766, -7.287093162536621, 11.159339904785157, -3.7541093826293947] }, { "array": [51.816688537597659, -22.891870498657228, -29.805912017822267, -68.66901397705078, -1.9484624862670899, 1.6389532089233399, -33.593971252441409, 17.095460891723634, -13.046170234680176, -8.923750877380371, 13.052698135375977, -5.068996429443359] }, { "array": [54.392459869384769, -23.71658706665039, -31.424976348876954, -75.520263671875, -4.738470077514648, -0.12287521362304688, -36.41456604003906, 18.125713348388673, -14.02833366394043, -11.63118839263916, 12.234237670898438, -4.546117782592773] }] }] }
friend/frontend/src/components/ChatInput.tsx ADDED
@@ -0,0 +1,672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, useCallback, useEffect } from 'react'
2
+ import { MessageCircle, Send, Loader, Mic, ChevronDown, History, SquarePen, Plus, Phone, PhoneOff } from 'lucide-react'
3
+ import { FRIEND_API } from '../api'
4
+
5
+ // Keyframes (claw-input-slide-up, claw-input-slide-down, claw-pulse) are in index.html <style>
6
+
7
+ const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
8
+
9
+ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', onHistoryOpen, onNewSession, language = 'zh' }: { visible?: boolean; onActiveChange?: (hasText: boolean) => void; uiAlign?: 'left' | 'right'; onHistoryOpen?: () => void; onNewSession?: () => void; language?: 'zh' | 'en' }) {
10
+ const t = (zh: string, en: string) => language === 'en' ? en : zh
11
+ const [open, setOpen] = useState(false)
12
+ const [text, setText] = useState('')
13
+ const [sending, setSending] = useState(false)
14
+ const [recording, setRecording] = useState(false)
15
+ const [closing, setClosing] = useState(false)
16
+ const [menuOpen, setMenuOpen] = useState(false)
17
+ const [voiceCallActive, setVoiceCallActive] = useState(false)
18
+ const inputRef = useRef<HTMLInputElement>(null)
19
+ const recognitionRef = useRef<any>(null)
20
+ const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
21
+ const lastSentIndexRef = useRef(0)
22
+ const voiceCallActiveRef = useRef(false)
23
+
24
+ const closeBar = useCallback(() => {
25
+ if (closing) return
26
+ setMenuOpen(false)
27
+ setClosing(true)
28
+ setTimeout(() => { setOpen(false); setClosing(false) }, 220)
29
+ }, [closing])
30
+
31
+ const send = useCallback(async (msg?: string) => {
32
+ const finalMsg = (msg ?? text).trim()
33
+ if (!finalMsg || sending) return
34
+ setSending(true)
35
+ setText('')
36
+ onActiveChange?.(false)
37
+ try {
38
+ await fetch(`${FRIEND_API}/chat`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ message: finalMsg }),
42
+ })
43
+ } catch (err) {
44
+ console.error('Failed to send message:', err)
45
+ } finally {
46
+ setSending(false)
47
+ }
48
+ }, [text, sending])
49
+
50
+ const startRecording = useCallback(async () => {
51
+ // Web Speech API (Tauri native STT not available in web mode)
52
+ if (!SpeechRecognition) {
53
+ console.error('SpeechRecognition not supported')
54
+ return
55
+ }
56
+
57
+ const recognition = new SpeechRecognition()
58
+ recognition.lang = 'zh-CN'
59
+ recognition.interimResults = true
60
+ recognition.continuous = true
61
+
62
+ recognition.onresult = (event: any) => {
63
+ let transcript = ''
64
+ for (let i = 0; i < event.results.length; i++) {
65
+ transcript += event.results[i][0].transcript
66
+ }
67
+ setText(transcript)
68
+ onActiveChange?.(transcript.length > 0)
69
+ }
70
+
71
+ recognition.onerror = (event: any) => {
72
+ console.error('Speech recognition error:', event.error)
73
+ setRecording(false)
74
+ }
75
+
76
+ recognition.onend = () => {
77
+ setRecording(false)
78
+ setTimeout(() => inputRef.current?.focus(), 50)
79
+ }
80
+
81
+ recognition.start()
82
+ recognitionRef.current = recognition
83
+ setRecording(true)
84
+ setOpen(true)
85
+ }, [])
86
+
87
+ const stopRecording = useCallback(() => {
88
+ if (recognitionRef.current) {
89
+ recognitionRef.current.stop()
90
+ recognitionRef.current = null
91
+ }
92
+ setRecording(false)
93
+ }, [])
94
+
95
+ // 鼠标移出按钮时也停止录音
96
+ const handleMouseLeave = useCallback(() => {
97
+ if (recording) stopRecording()
98
+ }, [recording, stopRecording])
99
+
100
+ // --- Voice Call: constants ---
101
+ const HARD_PUNCT = /[。!?\.\!\?]$/ // immediate send
102
+ const SOFT_PUNCT = /[,、;,;::]$/ // send if long enough
103
+ const SOFT_PUNCT_MIN_LEN = 10 // min chars before soft punct triggers send
104
+ const MAX_UNSENT_LEN = 30 // force send when accumulated text is this long
105
+ const SILENCE_SEND_MS = 1200 // silence fallback timeout
106
+ const VAD_RMS_THRESHOLD = 0.015 // RMS below this = silence
107
+ const VAD_SILENCE_TIMEOUT_MS = 15_000 // stop STT after 15s silence
108
+
109
+ // --- Voice Call: delayed TTS interrupt ---
110
+ const interruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
111
+ const scheduleInterrupt = useCallback(() => {
112
+ if (interruptTimerRef.current) return // already scheduled
113
+ interruptTimerRef.current = setTimeout(() => {
114
+ interruptTimerRef.current = null
115
+ ;(window as any).__clawInterruptAudio?.()
116
+ }, 1000)
117
+ }, [])
118
+ const cancelInterrupt = useCallback(() => {
119
+ if (interruptTimerRef.current) { clearTimeout(interruptTimerRef.current); interruptTimerRef.current = null }
120
+ }, [])
121
+
122
+ // --- Voice Call: refs ---
123
+ const vadStreamRef = useRef<MediaStream | null>(null)
124
+ const vadContextRef = useRef<AudioContext | null>(null)
125
+ const vadAnalyserRef = useRef<AnalyserNode | null>(null)
126
+ const vadRafRef = useRef<number>(0)
127
+ const vadSpeakingRef = useRef(false)
128
+ const vadSilenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
129
+ const [vadSpeaking, setVadSpeaking] = useState(false)
130
+
131
+ // --- Voice Call: send + mute capture for 3s after send ---
132
+ const VOICE_MUTE_AFTER_SEND_MS = 2000
133
+ const mutedUntilRef = useRef(0)
134
+
135
+ const voiceCallSend = useCallback(async (msg: string) => {
136
+ const trimmed = msg.trim()
137
+ if (!trimmed) return
138
+ mutedUntilRef.current = Date.now() + VOICE_MUTE_AFTER_SEND_MS
139
+ cancelInterrupt()
140
+ ;(window as any).__clawInterruptAudio?.()
141
+ try {
142
+ await fetch(`${FRIEND_API}/chat`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({ message: trimmed }),
146
+ })
147
+ } catch (err) {
148
+ console.error('Voice call send failed:', err)
149
+ }
150
+ }, [cancelInterrupt])
151
+
152
+ // --- Voice Call: smart sentence segmentation ---
153
+ const handleVoiceCallResult = useCallback((fullTranscript: string, isFinal: boolean) => {
154
+ // Ignore STT results during post-send mute period
155
+ if (Date.now() < mutedUntilRef.current) {
156
+ lastSentIndexRef.current = fullTranscript.length
157
+ return
158
+ }
159
+
160
+ const unsent = fullTranscript.slice(lastSentIndexRef.current)
161
+ setText(unsent)
162
+ onActiveChange?.(unsent.length > 0)
163
+
164
+ // Reset silence send timer on any result
165
+ if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null }
166
+
167
+ if (isFinal) {
168
+ const shouldSend =
169
+ HARD_PUNCT.test(unsent) || // hard punctuation
170
+ (SOFT_PUNCT.test(unsent) && unsent.length >= SOFT_PUNCT_MIN_LEN) || // soft punct + long enough
171
+ unsent.length >= MAX_UNSENT_LEN // word count overflow
172
+
173
+ if (shouldSend) {
174
+ voiceCallSend(unsent)
175
+ lastSentIndexRef.current = fullTranscript.length
176
+ setText('')
177
+ onActiveChange?.(false)
178
+ return
179
+ }
180
+ }
181
+
182
+ // Silence fallback: start timer on both partial and final results
183
+ if (unsent.trim()) {
184
+ silenceTimerRef.current = setTimeout(() => {
185
+ if (!voiceCallActiveRef.current) return
186
+ const chunk = unsent.trim()
187
+ if (chunk) {
188
+ voiceCallSend(chunk)
189
+ lastSentIndexRef.current = fullTranscript.length
190
+ setText('')
191
+ onActiveChange?.(false)
192
+ }
193
+ }, SILENCE_SEND_MS)
194
+ }
195
+ }, [voiceCallSend, onActiveChange])
196
+
197
+ // --- Voice Call: VAD using AnalyserNode ---
198
+ const startVad = useCallback(async () => {
199
+ try {
200
+ const stream = await navigator.mediaDevices.getUserMedia({
201
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
202
+ })
203
+ vadStreamRef.current = stream
204
+ const ctx = new AudioContext()
205
+ vadContextRef.current = ctx
206
+ const source = ctx.createMediaStreamSource(stream)
207
+ const analyser = ctx.createAnalyser()
208
+ analyser.fftSize = 512
209
+ source.connect(analyser)
210
+ vadAnalyserRef.current = analyser
211
+
212
+ const dataArray = new Float32Array(analyser.fftSize)
213
+ let lastSpeechTime = performance.now()
214
+
215
+ const check = () => {
216
+ if (!voiceCallActiveRef.current) return
217
+ analyser.getFloatTimeDomainData(dataArray)
218
+ let sum = 0
219
+ for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i]
220
+ const rms = Math.sqrt(sum / dataArray.length)
221
+
222
+ const speaking = rms > VAD_RMS_THRESHOLD
223
+ if (speaking) {
224
+ lastSpeechTime = performance.now()
225
+ if (!vadSpeakingRef.current) {
226
+ vadSpeakingRef.current = true
227
+ setVadSpeaking(true)
228
+ // Interrupt TTS after 1s delay when user starts speaking
229
+ scheduleInterrupt()
230
+ }
231
+ } else if (vadSpeakingRef.current && performance.now() - lastSpeechTime > 500) {
232
+ vadSpeakingRef.current = false
233
+ cancelInterrupt()
234
+ setVadSpeaking(false)
235
+ }
236
+
237
+ vadRafRef.current = requestAnimationFrame(check)
238
+ }
239
+ vadRafRef.current = requestAnimationFrame(check)
240
+ } catch (err) {
241
+ console.error('VAD start failed:', err)
242
+ }
243
+ }, [])
244
+
245
+ const stopVad = useCallback(() => {
246
+ if (vadRafRef.current) { cancelAnimationFrame(vadRafRef.current); vadRafRef.current = 0 }
247
+ vadStreamRef.current?.getTracks().forEach((t) => t.stop())
248
+ vadStreamRef.current = null
249
+ vadContextRef.current?.close()
250
+ vadContextRef.current = null
251
+ vadAnalyserRef.current = null
252
+ vadSpeakingRef.current = false
253
+ setVadSpeaking(false)
254
+ if (vadSilenceTimerRef.current) { clearTimeout(vadSilenceTimerRef.current); vadSilenceTimerRef.current = null }
255
+ }, [])
256
+
257
+ // --- Voice Call: start ---
258
+ const startVoiceCall = useCallback(async () => {
259
+ setVoiceCallActive(true)
260
+ voiceCallActiveRef.current = true
261
+ lastSentIndexRef.current = 0
262
+ setText('')
263
+ setOpen(true)
264
+
265
+ // Start VAD
266
+ await startVad()
267
+
268
+ if (!SpeechRecognition) {
269
+ console.error('SpeechRecognition not supported')
270
+ setVoiceCallActive(false)
271
+ voiceCallActiveRef.current = false
272
+ stopVad()
273
+ return
274
+ }
275
+
276
+ const startWebSTT = () => {
277
+ const recognition = new SpeechRecognition()
278
+ recognition.lang = 'zh-CN'
279
+ recognition.interimResults = true
280
+ recognition.continuous = true
281
+ recognitionRef.current = recognition
282
+
283
+ recognition.onresult = (event: any) => {
284
+ let transcript = ''
285
+ let latestFinalEnd = 0
286
+ for (let i = 0; i < event.results.length; i++) {
287
+ transcript += event.results[i][0].transcript
288
+ if (event.results[i].isFinal) latestFinalEnd = transcript.length
289
+ }
290
+ const hasFinal = latestFinalEnd > lastSentIndexRef.current
291
+ handleVoiceCallResult(transcript, hasFinal)
292
+ }
293
+
294
+ recognition.onerror = (event: any) => {
295
+ console.error('Voice call STT error:', event.error)
296
+ if (event.error === 'no-speech' || event.error === 'aborted') return
297
+ }
298
+
299
+ recognition.onend = () => {
300
+ // Auto-restart if call is still active
301
+ if (voiceCallActiveRef.current) {
302
+ lastSentIndexRef.current = 0
303
+ setTimeout(() => {
304
+ if (voiceCallActiveRef.current) startWebSTT()
305
+ }, 300)
306
+ }
307
+ }
308
+
309
+ recognition.start()
310
+ setRecording(true)
311
+ }
312
+
313
+ startWebSTT()
314
+ }, [handleVoiceCallResult, startVad, stopVad])
315
+
316
+ // --- Voice Call: end ---
317
+ const endVoiceCall = useCallback(() => {
318
+ voiceCallActiveRef.current = false
319
+ setVoiceCallActive(false)
320
+
321
+ // Stop VAD
322
+ stopVad()
323
+
324
+ // Stop STT
325
+ if (recognitionRef.current) {
326
+ recognitionRef.current.stop()
327
+ recognitionRef.current = null
328
+ }
329
+ setRecording(false)
330
+
331
+ // Clear timers
332
+ if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null }
333
+ cancelInterrupt()
334
+ mutedUntilRef.current = 0
335
+
336
+ setText('')
337
+ setOpen(false)
338
+ onActiveChange?.(false)
339
+ }, [onActiveChange, stopVad, cancelInterrupt])
340
+
341
+ const handleKeyDown = (e: React.KeyboardEvent) => {
342
+ if (e.key === 'Enter' && !e.shiftKey) {
343
+ e.preventDefault()
344
+ send()
345
+ }
346
+ if (e.key === 'Escape') {
347
+ closeBar()
348
+ }
349
+ if ((e.key === 'Delete') || (e.key === 'd' && e.ctrlKey)) {
350
+ e.preventDefault()
351
+ setText('')
352
+ onActiveChange?.(false)
353
+ }
354
+
355
+ }
356
+
357
+ // 全局快捷键
358
+ useEffect(() => {
359
+ const onGlobalKeyDown = (e: KeyboardEvent) => {
360
+ if (!visible) return
361
+ const inInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
362
+
363
+ // Enter: 唤起输入框 / 聚焦输入框
364
+ if (e.key === 'Enter' && !e.shiftKey && !inInput) {
365
+ e.preventDefault()
366
+ if (!open) {
367
+ setMenuOpen(false)
368
+ setOpen(true)
369
+ }
370
+ setTimeout(() => inputRef.current?.focus(), 50)
371
+ }
372
+
373
+ // Escape: 收起输入框
374
+ if (e.key === 'Escape' && open && !inInput) {
375
+ e.preventDefault()
376
+ closeBar()
377
+ }
378
+
379
+ // F2: 语音通话
380
+ if (e.key === 'F2') {
381
+ e.preventDefault()
382
+ if (voiceCallActive) {
383
+ endVoiceCall()
384
+ } else {
385
+ startVoiceCall()
386
+ }
387
+ }
388
+ }
389
+ const onGlobalKeyUp = (_e: KeyboardEvent) => {
390
+ // F2 keyup no longer needed (voice call is toggle, not push-to-talk)
391
+ }
392
+ window.addEventListener('keydown', onGlobalKeyDown)
393
+ window.addEventListener('keyup', onGlobalKeyUp)
394
+ return () => {
395
+ window.removeEventListener('keydown', onGlobalKeyDown)
396
+ window.removeEventListener('keyup', onGlobalKeyUp)
397
+ }
398
+ }, [open, visible, recording, voiceCallActive, startRecording, stopRecording, closeBar, startVoiceCall, endVoiceCall])
399
+
400
+ if (!visible) return null
401
+
402
+ if (!open) {
403
+ return (
404
+ <div style={{ position: 'absolute', bottom: 8, ...(uiAlign === 'left' ? { left: 12 } : { right: 12 }), zIndex: 300, pointerEvents: 'auto', display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'center' }}>
405
+ <button
406
+ onClick={startVoiceCall}
407
+ style={fabStyle}
408
+ title={t('语音通话 (F2)', 'Voice Call (F2)')}
409
+ >
410
+ <Phone size={16} />
411
+ </button>
412
+ <button
413
+ onClick={() => {
414
+ setMenuOpen(false)
415
+ setOpen(true)
416
+ setTimeout(() => inputRef.current?.focus(), 50)
417
+ }}
418
+ style={fabStyle}
419
+ title={t('发送消息 (Enter)', 'Send Message (Enter)')}
420
+ >
421
+ <MessageCircle size={16} />
422
+ </button>
423
+ </div>
424
+ )
425
+ }
426
+
427
+ if (voiceCallActive) {
428
+ return (
429
+ <div style={barStyle}>
430
+ <div style={{ flex: 1, position: 'relative' }}>
431
+ {/* 左侧 + 号按钮 */}
432
+ <button
433
+ onClick={() => setMenuOpen((v) => !v)}
434
+ style={{ ...inlineBtnLeft, color: menuOpen ? 'rgba(100, 160, 255, 0.9)' : 'rgba(255, 255, 255, 0.45)' }}
435
+ title={t('更多', 'More')}
436
+ >
437
+ <Plus size={22} />
438
+ </button>
439
+ {/* 弹出菜单 */}
440
+ {menuOpen && (
441
+ <div style={popupMenuStyle}>
442
+ <button onClick={() => { setMenuOpen(false); onHistoryOpen?.() }} style={popupItemStyle}>
443
+ <History size={14} />
444
+ <span>{t('对话历史', 'History')}</span>
445
+ </button>
446
+ <button onClick={() => { setMenuOpen(false); onNewSession?.() }} style={popupItemStyle}>
447
+ <SquarePen size={14} />
448
+ <span>{t('新会话', 'New Chat')}</span>
449
+ </button>
450
+ </div>
451
+ )}
452
+ <div
453
+ style={{
454
+ ...inputStyle,
455
+ paddingLeft: 48,
456
+ paddingRight: 80,
457
+ display: 'flex',
458
+ alignItems: 'center',
459
+ borderColor: vadSpeaking ? 'rgba(255, 80, 80, 0.7)' : 'rgba(255, 80, 80, 0.25)',
460
+ }}
461
+ data-no-passthrough
462
+ >
463
+ <span style={{ color: text ? '#fff' : 'rgba(255, 255, 255, 0.45)', fontSize: 18, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif' }}>
464
+ {text || (vadSpeaking ? t('正在听...', 'Listening...') : t('等待说话...', 'Waiting to speak...'))}
465
+
466
+ </span>
467
+ </div>
468
+ {/* 右侧:麦克风指示 + 挂断 */}
469
+ <span
470
+ style={{
471
+ ...inlineBtnRight,
472
+ right: 44,
473
+ color: vadSpeaking ? 'rgba(255, 80, 80, 0.9)' : 'rgba(80, 200, 120, 0.9)',
474
+ animation: vadSpeaking ? 'claw-pulse 0.8s ease-in-out infinite' : 'none',
475
+ transition: 'color 0.2s',
476
+ cursor: 'default',
477
+ }}
478
+ >
479
+ <Mic size={22} />
480
+ </span>
481
+ <button
482
+ onClick={endVoiceCall}
483
+ style={{ ...inlineBtnRight, right: 12, color: 'rgba(255, 80, 80, 0.9)' }}
484
+ title={t('挂断 (F2)', 'Hang up (F2)')}
485
+ >
486
+ <PhoneOff size={22} />
487
+ </button>
488
+ </div>
489
+ </div>
490
+ )
491
+ }
492
+
493
+ return (
494
+ <>
495
+ <div
496
+ key={closing ? 'closing' : 'open'}
497
+ style={{
498
+ ...barStyle,
499
+ animation: closing
500
+ ? 'claw-input-slide-down 0.2s ease-in forwards'
501
+ : 'claw-input-slide-up 0.25s ease-out both',
502
+ }}
503
+ >
504
+ <div style={{ flex: 1, position: 'relative' }}>
505
+ {/* 左侧 + 号按钮 */}
506
+ <button
507
+ onClick={() => setMenuOpen((v) => !v)}
508
+ style={{ ...inlineBtnLeft, color: menuOpen ? 'rgba(100, 160, 255, 0.9)' : 'rgba(255, 255, 255, 0.45)' }}
509
+ title={t('更多', 'More')}
510
+ >
511
+ <Plus size={22} />
512
+ </button>
513
+ {/* 弹出菜单 */}
514
+ {menuOpen && (
515
+ <div style={popupMenuStyle}>
516
+ <button onClick={() => { setMenuOpen(false); onHistoryOpen?.() }} style={popupItemStyle}>
517
+ <History size={14} />
518
+ <span>{t('对话历史', 'History')}</span>
519
+ </button>
520
+ <button onClick={() => { setMenuOpen(false); onNewSession?.() }} style={popupItemStyle}>
521
+ <SquarePen size={14} />
522
+ <span>{t('新会话', 'New Chat')}</span>
523
+ </button>
524
+ </div>
525
+ )}
526
+ <input
527
+ ref={inputRef}
528
+ value={text}
529
+ onChange={(e) => {
530
+ setText(e.target.value)
531
+ onActiveChange?.(e.target.value.length > 0)
532
+ }}
533
+ onKeyDown={handleKeyDown}
534
+ placeholder={sending ? t('思考中...', 'Thinking...') : ''}
535
+ disabled={sending}
536
+ style={{ ...inputStyle, paddingLeft: 48, paddingRight: 80 }}
537
+ autoFocus
538
+ />
539
+ {/* 右侧:发送/收起 */}
540
+ <button
541
+ onMouseDown={startRecording}
542
+ onMouseUp={stopRecording}
543
+ onMouseLeave={handleMouseLeave}
544
+ style={{ ...inlineBtnRight, right: 44, color: recording ? 'rgba(255, 80, 80, 0.9)' : 'rgba(255, 255, 255, 0.45)' }}
545
+ title={t('按住说话', 'Hold to speak')}
546
+ >
547
+ <Mic size={22} />
548
+ </button>
549
+ <button
550
+ onClick={() => text.trim() ? send() : closeBar()}
551
+ disabled={sending}
552
+ style={{ ...inlineBtnRight, right: 12, color: text.trim() ? 'rgba(100, 160, 255, 0.9)' : 'rgba(255, 255, 255, 0.45)' }}
553
+ title={text.trim() ? t('发送 (Enter)', 'Send (Enter)') : t('收起 (Esc)', 'Collapse (Esc)')}
554
+ >
555
+ {sending ? <Loader size={22} style={{ animation: 'spin 1s linear infinite' }} /> : text.trim() ? <Send size={22} /> : <ChevronDown size={22} />}
556
+ </button>
557
+ </div>
558
+ </div>
559
+ </>
560
+ )
561
+ }
562
+
563
+ const fabStyle: React.CSSProperties = {
564
+ width: 32,
565
+ height: 32,
566
+ border: 'none',
567
+ borderRadius: 6,
568
+ background: 'rgba(125, 125, 125, 0.28)',
569
+ backdropFilter: 'blur(6px)',
570
+ color: 'rgba(255, 255, 255, 0.8)',
571
+ fontSize: 16,
572
+ cursor: 'pointer',
573
+ display: 'flex',
574
+ alignItems: 'center',
575
+ justifyContent: 'center',
576
+ zIndex: 300,
577
+ pointerEvents: 'auto',
578
+ }
579
+
580
+ const barStyle: React.CSSProperties = {
581
+ position: 'absolute',
582
+ bottom: 16,
583
+ left: 12,
584
+ right: 12,
585
+ display: 'flex',
586
+ gap: 4,
587
+ zIndex: 300,
588
+ pointerEvents: 'auto',
589
+ }
590
+
591
+ const inputStyle: React.CSSProperties = {
592
+ width: '100%',
593
+ height: 50,
594
+ boxSizing: 'border-box' as const,
595
+ border: '1px solid rgba(255, 255, 255, 0.2)',
596
+ borderRadius: 25,
597
+ background: 'rgba(0, 0, 0, 0.4)',
598
+ backdropFilter: 'blur(6px)',
599
+ color: '#fff',
600
+ fontSize: 18,
601
+ padding: '0 10px',
602
+ outline: 'none',
603
+ fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
604
+ }
605
+
606
+ const inlineBtnLeft: React.CSSProperties = {
607
+ position: 'absolute',
608
+ left: 12,
609
+ top: '50%',
610
+ transform: 'translateY(-50%)',
611
+ width: 32,
612
+ height: 32,
613
+ border: 'none',
614
+ borderRadius: 4,
615
+ background: 'transparent',
616
+ cursor: 'pointer',
617
+ display: 'flex',
618
+ alignItems: 'center',
619
+ justifyContent: 'center',
620
+ padding: 0,
621
+ zIndex: 2,
622
+ }
623
+
624
+ const inlineBtnRight: React.CSSProperties = {
625
+ position: 'absolute',
626
+ top: '50%',
627
+ transform: 'translateY(-50%)',
628
+ width: 32,
629
+ height: 32,
630
+ border: 'none',
631
+ borderRadius: 4,
632
+ background: 'transparent',
633
+ cursor: 'pointer',
634
+ display: 'flex',
635
+ alignItems: 'center',
636
+ justifyContent: 'center',
637
+ padding: 0,
638
+ zIndex: 1,
639
+ }
640
+
641
+ const popupMenuStyle: React.CSSProperties = {
642
+ position: 'absolute',
643
+ left: 4,
644
+ bottom: '100%',
645
+ marginBottom: 6,
646
+ background: 'rgba(30, 30, 40, 0.95)',
647
+ backdropFilter: 'blur(12px)',
648
+ borderRadius: 10,
649
+ border: '1px solid rgba(255, 255, 255, 0.15)',
650
+ boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
651
+ padding: 4,
652
+ display: 'flex',
653
+ flexDirection: 'column',
654
+ gap: 2,
655
+ zIndex: 1000,
656
+ }
657
+
658
+ const popupItemStyle: React.CSSProperties = {
659
+ display: 'flex',
660
+ alignItems: 'center',
661
+ gap: 8,
662
+ padding: '8px 14px',
663
+ border: 'none',
664
+ borderRadius: 8,
665
+ background: 'transparent',
666
+ color: 'rgba(255, 255, 255, 0.8)',
667
+ fontSize: 13,
668
+ cursor: 'pointer',
669
+ whiteSpace: 'nowrap',
670
+ fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
671
+ }
672
+
friend/frontend/src/components/HistoryPanel.tsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { X } from 'lucide-react'
3
+ import { FRIEND_API } from '../api'
4
+
5
+ interface HistoryMessage {
6
+ role: string
7
+ content: string
8
+ timestamp?: number
9
+ }
10
+
11
+ interface HistoryPanelProps {
12
+ visible: boolean
13
+ onClose: () => void
14
+ language?: 'zh' | 'en'
15
+ }
16
+
17
+ export function HistoryPanel({ visible, onClose, language = 'zh' }: HistoryPanelProps) {
18
+ const t = (zh: string, en: string) => language === 'en' ? en : zh
19
+ const [messages, setMessages] = useState<HistoryMessage[]>([])
20
+ const [agentName, setAgentName] = useState('')
21
+ const [loading, setLoading] = useState(false)
22
+ const scrollRef = useRef<HTMLDivElement>(null)
23
+
24
+ // Drag state
25
+ const [panelPos, setPanelPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
26
+ const dragRef = useRef<{ dragging: boolean; startX: number; startY: number; origX: number; origY: number }>({
27
+ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0,
28
+ })
29
+
30
+ const onDragStart = useCallback((e: React.MouseEvent) => {
31
+ dragRef.current = { dragging: true, startX: e.clientX, startY: e.clientY, origX: panelPos.x, origY: panelPos.y }
32
+ const onMove = (ev: MouseEvent) => {
33
+ if (!dragRef.current.dragging) return
34
+ setPanelPos({
35
+ x: dragRef.current.origX + ev.clientX - dragRef.current.startX,
36
+ y: dragRef.current.origY + ev.clientY - dragRef.current.startY,
37
+ })
38
+ }
39
+ const onUp = () => {
40
+ dragRef.current.dragging = false
41
+ window.removeEventListener('mousemove', onMove)
42
+ window.removeEventListener('mouseup', onUp)
43
+ }
44
+ window.addEventListener('mousemove', onMove)
45
+ window.addEventListener('mouseup', onUp)
46
+ }, [panelPos])
47
+
48
+ useEffect(() => {
49
+ if (visible) setPanelPos({ x: 0, y: 0 })
50
+ }, [visible])
51
+
52
+ // Fetch history when panel opens
53
+ useEffect(() => {
54
+ if (!visible) return
55
+ setLoading(true)
56
+ fetch(`${FRIEND_API}/history`)
57
+ .then((r) => r.json())
58
+ .then((data) => {
59
+ setMessages(data.messages || [])
60
+ if (data.agentName) setAgentName(data.agentName)
61
+ setTimeout(() => {
62
+ if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight
63
+ }, 50)
64
+ })
65
+ .catch(() => setMessages([]))
66
+ .finally(() => setLoading(false))
67
+ }, [visible])
68
+
69
+ if (!visible) return null
70
+
71
+ return (
72
+ <div style={overlayStyle} data-no-passthrough onClick={onClose}>
73
+ <div style={{ ...panelStyle, transform: `translate(${panelPos.x}px, ${panelPos.y}px)` }} data-no-passthrough onClick={(e) => e.stopPropagation()}>
74
+ <div style={headerStyle} onMouseDown={onDragStart}>
75
+ <span style={{ fontSize: 16, fontWeight: 600, cursor: 'grab' }}>{t('对话历史', 'Chat History')}</span>
76
+ <button onClick={onClose} style={closeBtnStyle}>
77
+ <X size={16} />
78
+ </button>
79
+ </div>
80
+
81
+ <div ref={scrollRef} style={messagesContainerStyle}>
82
+ {loading && (
83
+ <div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.4)', padding: 20 }}>{t('加载中...', 'Loading...')}</div>
84
+ )}
85
+ {!loading && messages.length === 0 && (
86
+ <div style={{ textAlign: 'center', color: 'rgba(255,255,255,0.4)', padding: 20 }}>{t('暂无对话记录', 'No chat history')}</div>
87
+ )}
88
+ {messages.map((msg, i) => (
89
+ <div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start' }}>
90
+ <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', marginBottom: 2 }}>
91
+ {msg.role === 'user' ? t('你', 'You') : (agentName || 'AI')}
92
+ </div>
93
+ <div style={{
94
+ ...bubbleStyle,
95
+ background: msg.role === 'user' ? 'rgba(100, 160, 255, 0.3)' : 'rgba(255, 255, 255, 0.08)',
96
+ borderColor: msg.role === 'user' ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.12)',
97
+ }}>
98
+ {msg.content}
99
+ </div>
100
+ </div>
101
+ ))}
102
+ </div>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ const overlayStyle: React.CSSProperties = {
109
+ position: 'fixed',
110
+ top: 0,
111
+ left: 0,
112
+ right: 0,
113
+ bottom: 0,
114
+ background: 'rgba(0, 0, 0, 0.5)',
115
+ zIndex: 1000,
116
+ display: 'flex',
117
+ alignItems: 'center',
118
+ justifyContent: 'center',
119
+ pointerEvents: 'auto',
120
+ }
121
+
122
+ const panelStyle: React.CSSProperties = {
123
+ width: 360,
124
+ maxHeight: '80vh',
125
+ background: 'rgba(30, 30, 40, 0.95)',
126
+ backdropFilter: 'blur(12px)',
127
+ borderRadius: 12,
128
+ border: '1px solid rgba(255, 255, 255, 0.15)',
129
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)',
130
+ padding: 16,
131
+ color: '#fff',
132
+ fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
133
+ display: 'flex',
134
+ flexDirection: 'column',
135
+ }
136
+
137
+ const headerStyle: React.CSSProperties = {
138
+ display: 'flex',
139
+ justifyContent: 'space-between',
140
+ alignItems: 'center',
141
+ marginBottom: 12,
142
+ cursor: 'grab',
143
+ userSelect: 'none',
144
+ }
145
+
146
+ const closeBtnStyle: React.CSSProperties = {
147
+ width: 28,
148
+ height: 28,
149
+ border: 'none',
150
+ borderRadius: 6,
151
+ background: 'rgba(255, 255, 255, 0.1)',
152
+ color: 'rgba(255, 255, 255, 0.7)',
153
+ cursor: 'pointer',
154
+ display: 'flex',
155
+ alignItems: 'center',
156
+ justifyContent: 'center',
157
+ }
158
+
159
+ const messagesContainerStyle: React.CSSProperties = {
160
+ flex: 1,
161
+ overflowY: 'auto',
162
+ display: 'flex',
163
+ flexDirection: 'column',
164
+ gap: 12,
165
+ maxHeight: 'calc(80vh - 60px)',
166
+ }
167
+
168
+ const bubbleStyle: React.CSSProperties = {
169
+ maxWidth: '85%',
170
+ padding: '8px 12px',
171
+ borderRadius: 10,
172
+ border: '1px solid',
173
+ fontSize: 13,
174
+ lineHeight: 1.5,
175
+ wordBreak: 'break-word',
176
+ whiteSpace: 'pre-wrap',
177
+ }
friend/frontend/src/components/MoodIndicator.tsx ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FRIEND_API } from '../api'
2
+ import { useState, useEffect, useRef, useCallback } from 'react'
3
+
4
+ interface MoodBubble {
5
+ id: number
6
+ delta: number
7
+ }
8
+
9
+ interface MoodIndicatorProps {
10
+ uiAlign?: 'left' | 'right'
11
+ }
12
+
13
+ const BAR_WIDTH = 10
14
+ const BAR_HEIGHT = 120
15
+ const BORDER_GAP = 4 // gap between bar and border
16
+ const BORDER_WIDTH = 2 // border stroke thickness
17
+ const HEART_SIZE = 23
18
+ const DPR = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1
19
+
20
+ // Wave parameters (ported from Android HorizontalWaveProgressView)
21
+ const WAVE_LENGTH = 8
22
+ const WAVE_HEIGHT = 2.5
23
+
24
+ // 5 tiers: 90+ pink, 70+ orange, 50+ green, 30+ blue, 0+ grey
25
+ function moodRGB(percent: number): [number, number, number] {
26
+ if (percent >= 90) return [255, 107, 157]
27
+ if (percent >= 70) return [255, 165, 70]
28
+ if (percent >= 50) return [72, 199, 142]
29
+ if (percent >= 30) return [78, 168, 222]
30
+ return [160, 168, 180]
31
+ }
32
+
33
+ /** Rounded-rect path helper */
34
+ function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
35
+ ctx.beginPath()
36
+ ctx.moveTo(x + r, y)
37
+ ctx.lineTo(x + w - r, y)
38
+ ctx.arc(x + w - r, y + r, r, -Math.PI / 2, 0)
39
+ ctx.lineTo(x + w, y + h - r)
40
+ ctx.arc(x + w - r, y + h - r, r, 0, Math.PI / 2)
41
+ ctx.lineTo(x + r, y + h)
42
+ ctx.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI)
43
+ ctx.lineTo(x, y + r)
44
+ ctx.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5)
45
+ ctx.closePath()
46
+ }
47
+
48
+ /** Heart path centered at (cx, cy) with given size — classic round heart */
49
+ function heartPath(ctx: CanvasRenderingContext2D, cx: number, cy: number, size: number) {
50
+ const r = size * 0.45
51
+ const topY = cy - r * 0.4
52
+ ctx.beginPath()
53
+ // Bottom tip (rounded)
54
+ ctx.moveTo(cx, cy + r * 0.85)
55
+ // Left curve
56
+ ctx.bezierCurveTo(cx - r * 0.5, cy + r * 0.6, cx - r * 1.1, cy + r * 0.1, cx - r, topY)
57
+ // Left bump (arc)
58
+ ctx.arc(cx - r * 0.5, topY, r * 0.5, Math.PI, 0, false)
59
+ // Right bump (arc)
60
+ ctx.arc(cx + r * 0.5, topY, r * 0.5, Math.PI, 0, false)
61
+ // Right curve
62
+ ctx.bezierCurveTo(cx + r * 1.1, cy + r * 0.1, cx + r * 0.5, cy + r * 0.6, cx, cy + r * 0.85)
63
+ ctx.closePath()
64
+ }
65
+
66
+ /**
67
+ * Common wave drawing logic used by both the bar and the heart.
68
+ * Draws dual bezier waves inside the current clip region.
69
+ */
70
+ function drawWaves(
71
+ ctx: CanvasRenderingContext2D,
72
+ regionWidth: number,
73
+ regionHeight: number,
74
+ waterY: number,
75
+ moveDistance: number,
76
+ cr: number, cg: number, cb: number,
77
+ ) {
78
+ const fullCycle = WAVE_LENGTH * 2
79
+ const waveNumber = Math.ceil(regionWidth / fullCycle) + 2
80
+
81
+ const drawWave = (scrollDir: number, alpha: number) => {
82
+ ctx.beginPath()
83
+ const offset = (moveDistance * scrollDir) % fullCycle
84
+ let x = -fullCycle + offset
85
+ ctx.moveTo(x, waterY)
86
+
87
+ for (let i = 0; i < waveNumber * 2; i++) {
88
+ ctx.quadraticCurveTo(x + WAVE_LENGTH / 2, waterY - WAVE_HEIGHT, x + WAVE_LENGTH, waterY)
89
+ x += WAVE_LENGTH
90
+ ctx.quadraticCurveTo(x + WAVE_LENGTH / 2, waterY + WAVE_HEIGHT, x + WAVE_LENGTH, waterY)
91
+ x += WAVE_LENGTH
92
+ }
93
+
94
+ ctx.lineTo(x, regionHeight)
95
+ ctx.lineTo(-fullCycle, regionHeight)
96
+ ctx.closePath()
97
+ ctx.fillStyle = `rgba(${cr},${cg},${cb},${alpha})`
98
+ ctx.fill()
99
+ }
100
+
101
+ drawWave(-1, 0.55)
102
+ drawWave(1, 1.0)
103
+ }
104
+
105
+ /**
106
+ * Draw the vertical liquid-filled bar with border that has a gap.
107
+ */
108
+ function drawLiquidBar(
109
+ ctx: CanvasRenderingContext2D,
110
+ w: number,
111
+ h: number,
112
+ percent: number,
113
+ moveDistance: number,
114
+ ) {
115
+ const radius = w / 2
116
+ const totalW = w + (BORDER_GAP + BORDER_WIDTH) * 2
117
+ const totalH = h + (BORDER_GAP + BORDER_WIDTH) * 2
118
+ const offsetX = BORDER_GAP + BORDER_WIDTH
119
+ const offsetY = BORDER_GAP + BORDER_WIDTH
120
+
121
+ ctx.clearRect(0, 0, totalW, totalH)
122
+
123
+ const waterY = h * (1 - percent / 100)
124
+ const [cr, cg, cb] = moodRGB(percent)
125
+
126
+ // ── Bar fill ──
127
+ ctx.save()
128
+ roundedRectPath(ctx, offsetX, offsetY, w, h, radius)
129
+ ctx.clip()
130
+
131
+ ctx.clearRect(offsetX, offsetY, w, h)
132
+
133
+ ctx.save()
134
+ ctx.translate(offsetX, offsetY)
135
+ drawWaves(ctx, w, h, waterY, moveDistance, cr, cg, cb)
136
+ ctx.restore()
137
+
138
+ ctx.restore()
139
+
140
+ // ── Border with gap ──
141
+ const bx = offsetX - BORDER_GAP - BORDER_WIDTH / 2
142
+ const by = offsetY - BORDER_GAP - BORDER_WIDTH / 2
143
+ const bw = w + (BORDER_GAP + BORDER_WIDTH / 2) * 2
144
+ const bh = h + (BORDER_GAP + BORDER_WIDTH / 2) * 2
145
+ const br = radius + BORDER_GAP + BORDER_WIDTH / 2
146
+ roundedRectPath(ctx, bx, by, bw, bh, br)
147
+ ctx.strokeStyle = 'rgba(80, 80, 85, 0.75)'
148
+ ctx.lineWidth = BORDER_WIDTH
149
+ ctx.stroke()
150
+ }
151
+
152
+ /**
153
+ * Draw liquid-filled heart icon with border and gap (matching bar style).
154
+ */
155
+ function drawLiquidHeart(
156
+ ctx: CanvasRenderingContext2D,
157
+ size: number,
158
+ percent: number,
159
+ moveDistance: number,
160
+ ) {
161
+ ctx.clearRect(0, 0, size, size)
162
+
163
+ const cx = size / 2
164
+ const cy = size / 2
165
+ const waterY = size * (1 - percent / 100)
166
+ const [cr, cg, cb] = moodRGB(percent)
167
+
168
+ // ── Heart fill ──
169
+ ctx.save()
170
+ heartPath(ctx, cx, cy, size)
171
+ ctx.clip()
172
+
173
+ ctx.fillStyle = 'rgba(60, 60, 65, 0.9)'
174
+ ctx.fillRect(0, 0, size, size)
175
+
176
+ drawWaves(ctx, size, size, waterY, moveDistance, cr, cg, cb)
177
+
178
+ ctx.restore()
179
+ }
180
+
181
+ let bubbleIdCounter = 0
182
+
183
+ // Canvas total dimensions (bar + border gap)
184
+ const BAR_CANVAS_W = BAR_WIDTH + (BORDER_GAP + BORDER_WIDTH) * 2
185
+ const BAR_CANVAS_H = BAR_HEIGHT + (BORDER_GAP + BORDER_WIDTH) * 2
186
+
187
+ export function MoodIndicator({ uiAlign = 'right' }: MoodIndicatorProps) {
188
+ const [mood, setMood] = useState(60)
189
+ const [bubbles, setBubbles] = useState<MoodBubble[]>([])
190
+ const [visible, setVisible] = useState(false)
191
+ const [dragging, setDragging] = useState(false)
192
+ const defaultX = (align: string) => align === 'left' ? window.innerWidth - 20 - BAR_CANVAS_W : 20
193
+ const [pos, setPos] = useState<{ x: number; y: number }>({ x: defaultX(uiAlign), y: 20 })
194
+ const [userDragged, setUserDragged] = useState(false)
195
+ const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
196
+ const containerRef = useRef<HTMLDivElement>(null)
197
+ const barCanvasRef = useRef<HTMLCanvasElement>(null)
198
+ const heartCanvasRef = useRef<HTMLCanvasElement>(null)
199
+ const animRef = useRef<number>(0)
200
+ const displayPercentRef = useRef(60)
201
+ const moveDistRef = useRef(0)
202
+
203
+ useEffect(() => {
204
+ if (!userDragged) setPos(p => ({ ...p, x: defaultX(uiAlign) }))
205
+ }, [uiAlign, userDragged])
206
+
207
+ useEffect(() => {
208
+ fetch(`${FRIEND_API}/settings`)
209
+ .then((r) => r.json())
210
+ .then((s) => { if (s.moodIndex !== undefined) { setMood(s.moodIndex); displayPercentRef.current = s.moodIndex } })
211
+ .catch(() => {})
212
+ }, [])
213
+
214
+ const showBriefly = useCallback(() => {
215
+ setVisible(true)
216
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
217
+ hideTimerRef.current = setTimeout(() => setVisible(false), 5000)
218
+ }, [])
219
+
220
+ useEffect(() => {
221
+ const es = new EventSource(`${FRIEND_API}/events`)
222
+ es.onmessage = (e) => {
223
+ try {
224
+ const data = JSON.parse(e.data)
225
+ if (data.moodIndex !== undefined && data.moodDelta !== undefined) {
226
+ setMood(data.moodIndex)
227
+ showBriefly()
228
+ const id = ++bubbleIdCounter
229
+ setBubbles((prev) => [...prev, { id, delta: data.moodDelta }])
230
+ setTimeout(() => setBubbles((prev) => prev.filter((b) => b.id !== id)), 2000)
231
+ }
232
+ } catch { /* ignore */ }
233
+ }
234
+ return () => es.close()
235
+ }, [showBriefly])
236
+
237
+ // Canvas animation loop — draws both heart and bar
238
+ useEffect(() => {
239
+ const barCanvas = barCanvasRef.current
240
+ const heartCanvas = heartCanvasRef.current
241
+ if (!barCanvas || !heartCanvas) return
242
+ const barCtx = barCanvas.getContext('2d')
243
+ const heartCtx = heartCanvas.getContext('2d')
244
+ if (!barCtx || !heartCtx) return
245
+
246
+ barCanvas.width = BAR_CANVAS_W * DPR
247
+ barCanvas.height = BAR_CANVAS_H * DPR
248
+ barCtx.scale(DPR, DPR)
249
+
250
+ heartCanvas.width = HEART_SIZE * DPR
251
+ heartCanvas.height = HEART_SIZE * DPR
252
+ heartCtx.scale(DPR, DPR)
253
+
254
+ let running = true
255
+ const animate = () => {
256
+ if (!running) return
257
+ const target = Math.max(2, mood)
258
+ displayPercentRef.current += (target - displayPercentRef.current) * 0.08
259
+ moveDistRef.current += 0.12
260
+
261
+ drawLiquidBar(barCtx, BAR_WIDTH, BAR_HEIGHT, displayPercentRef.current, moveDistRef.current)
262
+ drawLiquidHeart(heartCtx, HEART_SIZE, displayPercentRef.current, moveDistRef.current)
263
+ animRef.current = requestAnimationFrame(animate)
264
+ }
265
+ animate()
266
+ return () => { running = false; cancelAnimationFrame(animRef.current) }
267
+ }, [mood])
268
+
269
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
270
+ e.preventDefault()
271
+ e.stopPropagation()
272
+ setDragging(true)
273
+ setUserDragged(true)
274
+ setVisible(true)
275
+ if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null }
276
+
277
+ const el = containerRef.current
278
+ if (!el) return
279
+ const rect = el.getBoundingClientRect()
280
+ const offsetX = e.clientX - rect.left
281
+ const offsetY = e.clientY - rect.top
282
+
283
+ const onMove = (ev: PointerEvent) => {
284
+ setPos({ x: ev.clientX - offsetX, y: ev.clientY - offsetY })
285
+ }
286
+ const onUp = () => {
287
+ setDragging(false)
288
+ window.removeEventListener('pointermove', onMove)
289
+ window.removeEventListener('pointerup', onUp)
290
+ hideTimerRef.current = setTimeout(() => setVisible(false), 3000)
291
+ }
292
+ window.addEventListener('pointermove', onMove)
293
+ window.addEventListener('pointerup', onUp)
294
+ }, [])
295
+
296
+ const handleMouseEnter = useCallback(() => {
297
+ setVisible(true)
298
+ if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null }
299
+ }, [])
300
+
301
+ const handleMouseLeave = useCallback(() => {
302
+ if (!dragging) {
303
+ hideTimerRef.current = setTimeout(() => setVisible(false), 2000)
304
+ }
305
+ }, [dragging])
306
+
307
+ return (
308
+ <div
309
+ ref={containerRef}
310
+ style={{
311
+ position: 'absolute',
312
+ top: pos.y,
313
+ left: pos.x,
314
+ zIndex: 250,
315
+ pointerEvents: 'auto',
316
+ display: 'flex',
317
+ flexDirection: 'column',
318
+ alignItems: 'center',
319
+ transition: 'opacity 0.5s ease',
320
+ opacity: visible || bubbles.length > 0 ? 1 : 0.5,
321
+ cursor: dragging ? 'grabbing' : 'grab',
322
+ userSelect: 'none',
323
+ }}
324
+ onMouseEnter={handleMouseEnter}
325
+ onMouseLeave={handleMouseLeave}
326
+ onPointerDown={handlePointerDown}
327
+ data-no-passthrough
328
+ >
329
+ {/* Liquid-filled heart */}
330
+ <canvas
331
+ ref={heartCanvasRef}
332
+ width={HEART_SIZE * DPR}
333
+ height={HEART_SIZE * DPR}
334
+ style={{ width: HEART_SIZE, height: HEART_SIZE, display: 'block', marginBottom: 4 }}
335
+ />
336
+
337
+ {/* Vertical liquid bar with border */}
338
+ <div style={{ position: 'relative', width: BAR_CANVAS_W, height: BAR_CANVAS_H }}>
339
+ <canvas
340
+ ref={barCanvasRef}
341
+ width={BAR_CANVAS_W * DPR}
342
+ height={BAR_CANVAS_H * DPR}
343
+ style={{ width: BAR_CANVAS_W, height: BAR_CANVAS_H, display: 'block' }}
344
+ />
345
+ {/* Floating bubbles */}
346
+ {bubbles.map((b) => (
347
+ <span
348
+ key={b.id}
349
+ style={{
350
+ position: 'absolute',
351
+ left: '50%',
352
+ top: -20,
353
+ transform: 'translateX(-50%)',
354
+ fontSize: 14,
355
+ fontWeight: 800,
356
+ color: b.delta > 0 ? '#FF6B9D' : '#7EB0D5',
357
+ whiteSpace: 'nowrap',
358
+ pointerEvents: 'none',
359
+ animation: 'mood-bubble-float 2.5s ease-out forwards',
360
+ fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
361
+ textShadow: b.delta > 0
362
+ ? '0 0 6px rgba(255,107,157,0.6), 0 1px 3px rgba(0,0,0,0.4)'
363
+ : '0 0 6px rgba(126,176,213,0.6), 0 1px 3px rgba(0,0,0,0.4)',
364
+ }}
365
+ >
366
+ {b.delta > 0 ? `❤️+${b.delta}` : `🩶${b.delta}`}
367
+ </span>
368
+ ))}
369
+ </div>
370
+ </div>
371
+ )
372
+ }
friend/frontend/src/components/ResizeHandles.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ResizeHandles — stub for Tauri window resize.
3
+ * Not applicable in web mode (browser window cannot be resized from content).
4
+ */
5
+ export function ResizeHandles() {
6
+ return null
7
+ }
friend/frontend/src/components/SettingsPanel.tsx ADDED
@@ -0,0 +1,1020 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useRef, useCallback } from 'react'
2
+ import { X, Play, Loader, Sparkles, Trash2, Upload, Music } from 'lucide-react'
3
+ import { FRIEND_API } from '../api'
4
+ import { dancePresets, type DancePreset } from '../motion-controller'
5
+ import { pickFile } from '../web-file-picker'
6
+
7
+ interface DanceItem {
8
+ id: string
9
+ label: string
10
+ vmdUrl: string
11
+ bgmUrl?: string
12
+ builtin?: boolean
13
+ }
14
+
15
+ interface SettingsPanelProps {
16
+ visible: boolean
17
+ onClose: () => void
18
+ currentModel: string
19
+ onModelChange: (path: string) => void
20
+ hideUI: boolean
21
+ onHideUIChange: (v: boolean) => void
22
+ showText: boolean
23
+ onShowTextChange: (v: boolean) => void
24
+ ttsEnabled: boolean
25
+ onTtsEnabledChange: (v: boolean) => void
26
+ tracking: 'mouse' | 'camera'
27
+ onTrackingChange: (v: 'mouse' | 'camera') => void
28
+ volume: number
29
+ onVolumeChange: (v: number) => void
30
+ uiAlign: 'left' | 'right'
31
+ onUiAlignChange: (v: 'left' | 'right') => void
32
+ hideMood: boolean
33
+ onHideMoodChange: (v: boolean) => void
34
+ screenObserve: boolean
35
+ onScreenObserveChange: (v: boolean) => void
36
+ screenObserveInterval: number
37
+ onScreenObserveIntervalChange: (v: number) => void
38
+ /** Return a data URL screenshot of the current VRM canvas */
39
+ captureVrmScreenshot?: () => string | null
40
+ language: 'zh' | 'en'
41
+ onLanguageChange: (v: 'zh' | 'en') => void
42
+ currentDance: string
43
+ onDanceChange: (id: string, preset?: DancePreset) => void
44
+ }
45
+
46
+ type Tab = 'general' | 'voice' | 'model' | 'persona' | 'dance'
47
+
48
+ const BUILTIN_MODELS = ['/friend/model1.vrm', '/friend/model2.vrm', '/friend/model3.vrm', '/friend/model4.vrm', '/friend/model5.vrm']
49
+
50
+ const EDGE_VOICES = [
51
+ { id: 'zh-CN-XiaoxiaoNeural', label: '晓晓 (女)' },
52
+ { id: 'zh-CN-XiaoyiNeural', label: '晓依 (女)' },
53
+ { id: 'zh-CN-YunxiNeural', label: '云希 (男)' },
54
+ { id: 'zh-CN-YunjianNeural', label: '云健 (男)' },
55
+ { id: 'zh-CN-XiaohanNeural', label: '晓涵 (女)' },
56
+ { id: 'zh-CN-XiaomoNeural', label: '晓墨 (女)' },
57
+ { id: 'zh-CN-XiaoxuanNeural', label: '晓萱 (女)' },
58
+ { id: 'zh-CN-YunyangNeural', label: '云扬 (男)' },
59
+ { id: 'zh-TW-HsiaoChenNeural', label: '曉臻 (女)' },
60
+ { id: 'ja-JP-NanamiNeural', label: 'Nanami (女)' },
61
+ { id: 'en-US-MichelleNeural', label: 'Michelle (F)' },
62
+ { id: 'en-US-GuyNeural', label: 'Guy (M)' },
63
+ ]
64
+
65
+ const QWEN_VOICES = [
66
+ { id: 'Cherry', label: '芊悦 - 阳光亲切 (女)' },
67
+ { id: 'Serena', label: '苏瑶 - 温柔 (女)' },
68
+ { id: 'Ethan', label: '晨煦 - 阳光温暖 (男)' },
69
+ { id: 'Chelsie', label: '千雪 - 二次元 (女)' },
70
+ { id: 'Momo', label: '茉兔 - 撒娇搞怪 (女)' },
71
+ { id: 'Vivian', label: '十三 - 可爱小暴躁 (女)' },
72
+ { id: 'Moon', label: '月白 - 率性帅气 (男)' },
73
+ { id: 'Maia', label: '四月 - 知性温柔 (女)' },
74
+ { id: 'Kai', label: '凯 - 耳朵SPA (男)' },
75
+ { id: 'Nofish', label: '不吃鱼 - 设计师 (男)' },
76
+ { id: 'Bella', label: '萌宝 - 小萝莉 (女)' },
77
+ { id: 'Mia', label: '乖小妹 - 温顺乖巧 (女)' },
78
+ { id: 'Mochi', label: '沙小弥 - 童真小大人 (男)' },
79
+ { id: 'Bunny', label: '萌小姬 - 萌属性 (女)' },
80
+ { id: 'Nini', label: '邻家妹妹 - 软糯甜蜜 (女)' },
81
+ { id: 'Stella', label: '少女阿月 - 迷糊少女 (女)' },
82
+ { id: 'Pip', label: '顽屁小孩 - 调皮捣蛋 (男)' },
83
+ { id: 'Neil', label: '阿闻 - 新闻主持 (男)' },
84
+ { id: 'Eldric Sage', label: '沧明子 - 沉稳老者 (男)' },
85
+ { id: 'Vincent', label: '田叔 - 沙哑烟嗓 (男)' },
86
+ { id: 'Bellona', label: '燕铮莺 - 有声书 (女)' },
87
+ { id: 'Seren', label: '小婉 - 温柔助眠 (女)' },
88
+ ]
89
+
90
+ const QWEN_MODELS = [
91
+ { id: 'qwen3-tts-flash', label: 'Qwen3 TTS Flash' },
92
+ ]
93
+
94
+ export function SettingsPanel({
95
+ visible, onClose, currentModel, onModelChange,
96
+ hideUI, onHideUIChange,
97
+ showText, onShowTextChange,
98
+ ttsEnabled, onTtsEnabledChange,
99
+ tracking, onTrackingChange,
100
+ volume, onVolumeChange,
101
+ uiAlign, onUiAlignChange,
102
+ hideMood, onHideMoodChange,
103
+ screenObserve, onScreenObserveChange,
104
+ screenObserveInterval, onScreenObserveIntervalChange,
105
+ captureVrmScreenshot,
106
+ language, onLanguageChange,
107
+ currentDance, onDanceChange,
108
+ }: SettingsPanelProps) {
109
+ const t = (zh: string, en: string) => language === 'en' ? en : zh
110
+
111
+ const [tab, setTab] = useState<Tab>('general')
112
+ const [models, setModels] = useState<string[]>([])
113
+ const [soulContent, setSoulContent] = useState('')
114
+ const [identityContent, setIdentityContent] = useState('')
115
+ const [personaDirty, setPersonaDirty] = useState(false)
116
+ const [personaSaving, setPersonaSaving] = useState(false)
117
+ const [generating, setGenerating] = useState(false)
118
+ const [currentVoice, setCurrentVoice] = useState('')
119
+ const [currentProvider, setCurrentProvider] = useState<string>('edge')
120
+ const [qwenKey, setQwenKey] = useState('')
121
+ const [qwenModel, setQwenModel] = useState('qwen3-tts-flash')
122
+ const [previewingId, setPreviewingId] = useState<string | null>(null)
123
+ const audioRef = useRef<HTMLAudioElement | null>(null)
124
+ const [customDances, setCustomDances] = useState<DanceItem[]>([])
125
+ const [importingDance, setImportingDance] = useState(false)
126
+
127
+
128
+ // Drag state
129
+ const [panelPos, setPanelPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
130
+ const dragRef = useRef<{ dragging: boolean; startX: number; startY: number; origX: number; origY: number }>({
131
+ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0,
132
+ })
133
+
134
+ const onDragStart = useCallback((e: React.MouseEvent) => {
135
+ dragRef.current = { dragging: true, startX: e.clientX, startY: e.clientY, origX: panelPos.x, origY: panelPos.y }
136
+ const onMove = (ev: MouseEvent) => {
137
+ if (!dragRef.current.dragging) return
138
+ setPanelPos({
139
+ x: dragRef.current.origX + ev.clientX - dragRef.current.startX,
140
+ y: dragRef.current.origY + ev.clientY - dragRef.current.startY,
141
+ })
142
+ }
143
+ const onUp = () => {
144
+ dragRef.current.dragging = false
145
+ window.removeEventListener('mousemove', onMove)
146
+ window.removeEventListener('mouseup', onUp)
147
+ }
148
+ window.addEventListener('mousemove', onMove)
149
+ window.addEventListener('mouseup', onUp)
150
+ }, [panelPos])
151
+
152
+ // Reset position when panel opens
153
+ useEffect(() => {
154
+ if (visible) setPanelPos({ x: 0, y: 0 })
155
+ }, [visible])
156
+
157
+ useEffect(() => {
158
+ if (!visible) return
159
+ fetch(`${FRIEND_API}/model/list`)
160
+ .then((r) => r.json())
161
+ .then((data) => { if (data.models) setModels(data.models) })
162
+ .catch(() => setModels([]))
163
+ // Fetch current voice + provider
164
+ fetch(`${FRIEND_API}/voice`)
165
+ .then((r) => r.json())
166
+ .then((data) => {
167
+ setCurrentVoice(data.voice || '')
168
+ setCurrentProvider(data.provider || 'edge')
169
+ if (data.qwenKey) setQwenKey(data.qwenKey)
170
+ if (data.qwenModel) setQwenModel(data.qwenModel)
171
+ })
172
+ .catch(() => {})
173
+ }, [visible])
174
+
175
+ // Fetch persona files when model tab / persona sub-tab is active
176
+ useEffect(() => {
177
+ if (!visible || tab !== 'persona') return
178
+ fetch(`${FRIEND_API}/persona`)
179
+ .then((r) => r.json())
180
+ .then((data) => {
181
+ setSoulContent(data.soul || '')
182
+ setIdentityContent(data.identity || '')
183
+ setPersonaDirty(false)
184
+ })
185
+ .catch(() => {})
186
+ }, [visible, tab])
187
+
188
+ const fetchCustomDances = useCallback(() => {
189
+ fetch(`${FRIEND_API}/dance/list`)
190
+ .then((r) => r.json())
191
+ .then((data) => { if (data.dances) setCustomDances(data.dances) })
192
+ .catch(() => setCustomDances([]))
193
+ }, [])
194
+
195
+ useEffect(() => {
196
+ if (!visible || tab !== 'dance') return
197
+ fetchCustomDances()
198
+ }, [visible, tab, fetchCustomDances])
199
+
200
+ const savePersona = useCallback(() => {
201
+ setPersonaSaving(true)
202
+ fetch(`${FRIEND_API}/persona`, {
203
+ method: 'POST',
204
+ headers: { 'Content-Type': 'application/json' },
205
+ body: JSON.stringify({ soul: soulContent, identity: identityContent }),
206
+ })
207
+ .then(() => { setPersonaDirty(false) })
208
+ .catch(() => {})
209
+ .finally(() => setPersonaSaving(false))
210
+ }, [soulContent, identityContent])
211
+
212
+ const generatePersona = useCallback(async () => {
213
+ if (!captureVrmScreenshot) return
214
+ const dataUrl = captureVrmScreenshot()
215
+ if (!dataUrl) return
216
+ setGenerating(true)
217
+ try {
218
+ // Save screenshot to server first
219
+ const saveRes = await fetch(`${FRIEND_API}/persona/screenshot`, {
220
+ method: 'POST',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ body: JSON.stringify({ image: dataUrl }),
223
+ })
224
+ const saveData = await saveRes.json()
225
+ if (!saveData.ok) throw new Error(saveData.error || 'save screenshot failed')
226
+ // Generate persona from saved screenshot
227
+ const genRes = await fetch(`${FRIEND_API}/persona/generate`, {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ })
231
+ const genData = await genRes.json()
232
+ if (genData.soul || genData.identity) {
233
+ if (genData.soul) setSoulContent(genData.soul)
234
+ if (genData.identity) setIdentityContent(genData.identity)
235
+ setPersonaDirty(true)
236
+ }
237
+ } catch { /* ignore */ }
238
+ setGenerating(false)
239
+ }, [captureVrmScreenshot])
240
+
241
+ const postVoiceSettings = (body: Record<string, string | undefined>) => {
242
+ return fetch(`${FRIEND_API}/voice`, {
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/json' },
245
+ body: JSON.stringify(body),
246
+ })
247
+ }
248
+
249
+ const saveModelPath = (modelPath: string) => {
250
+ fetch(`${FRIEND_API}/settings`, {
251
+ method: 'POST',
252
+ headers: { 'Content-Type': 'application/json' },
253
+ body: JSON.stringify({ modelPath }),
254
+ }).catch(() => {})
255
+ }
256
+
257
+ const setVoice = (voice: string) => {
258
+ postVoiceSettings({ voice }).then(() => setCurrentVoice(voice)).catch(() => {})
259
+ }
260
+
261
+ const setProvider = (provider: string) => {
262
+ postVoiceSettings({ provider }).then(() => setCurrentProvider(provider)).catch(() => {})
263
+ }
264
+
265
+ const saveQwenKey = (key: string) => {
266
+ postVoiceSettings({ qwenKey: key }).catch(() => {})
267
+ }
268
+
269
+ const saveQwenModel = (model: string) => {
270
+ postVoiceSettings({ qwenModel: model }).then(() => setQwenModel(model)).catch(() => {})
271
+ }
272
+
273
+ const stopPreview = useCallback(() => {
274
+ if (audioRef.current) {
275
+ audioRef.current.onended = null
276
+ audioRef.current.onerror = null
277
+ audioRef.current.pause()
278
+ audioRef.current = null
279
+ }
280
+ setPreviewingId(null)
281
+ }, [])
282
+
283
+ const preview = (voiceId: string) => {
284
+ // Stop any current preview first
285
+ stopPreview()
286
+ setPreviewingId(voiceId)
287
+ fetch(`${FRIEND_API}/preview`, {
288
+ method: 'POST',
289
+ headers: { 'Content-Type': 'application/json' },
290
+ body: JSON.stringify({ voice: voiceId, provider: currentProvider }),
291
+ })
292
+ .then((r) => r.json())
293
+ .then((data) => {
294
+ if (data.audioUrl) {
295
+ const audio = new Audio(data.audioUrl)
296
+ audioRef.current = audio
297
+ audio.onended = () => stopPreview()
298
+ audio.onerror = () => stopPreview()
299
+ audio.play().catch(() => stopPreview())
300
+ } else {
301
+ if (data.error) console.warn('TTS preview error:', data.error)
302
+ stopPreview()
303
+ }
304
+ })
305
+ .catch(() => stopPreview())
306
+ }
307
+
308
+ if (!visible) return null
309
+
310
+ const voices = currentProvider === 'qwen' ? QWEN_VOICES : EDGE_VOICES
311
+
312
+ return (
313
+ <div style={overlayStyle} data-no-passthrough onClick={onClose}>
314
+ <div style={{ ...panelStyle, transform: `translate(${panelPos.x}px, ${panelPos.y}px)` }} data-no-passthrough onClick={(e) => e.stopPropagation()}>
315
+ <div style={headerStyle} onMouseDown={onDragStart}>
316
+ <span style={{ fontSize: 16, fontWeight: 600, cursor: 'grab' }}>{t('设置', 'Settings')}</span>
317
+ <button onClick={onClose} style={closeBtnStyle}>
318
+ <X size={16} />
319
+ </button>
320
+ </div>
321
+
322
+ {/* Tabs */}
323
+ <div style={tabBarStyle}>
324
+ {(['general', 'voice', 'model', 'persona', 'dance'] as const).map((tb) => (
325
+ <button
326
+ key={tb}
327
+ onClick={() => setTab(tb)}
328
+ style={{ ...tabStyle, ...(tab === tb ? activeTabStyle : {}) }}
329
+ >
330
+ {{ general: t('常规', 'General'), voice: t('语音', 'Voice'), model: t('形象', 'Model'), persona: t('人设', 'Persona'), dance: t('舞蹈', 'Dance') }[tb]}
331
+ </button>
332
+ ))}
333
+ </div>
334
+
335
+ {/* Tab content */}
336
+ <div style={contentStyle}>
337
+ {tab === 'general' && (
338
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
339
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
340
+ <span style={{ fontSize: 14 }}>{t('语言', 'Language')}</span>
341
+ <div style={{ display: 'flex', gap: 4 }}>
342
+ {(['zh', 'en'] as const).map((l) => (
343
+ <button
344
+ key={l}
345
+ onClick={() => onLanguageChange(l)}
346
+ style={{
347
+ ...smallBtnStyle,
348
+ background: language === l ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.08)',
349
+ borderColor: language === l ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
350
+ }}
351
+ >
352
+ {l === 'zh' ? '中文' : 'English'}
353
+ </button>
354
+ ))}
355
+ </div>
356
+ </div>
357
+ <ToggleRow label={t('显示字幕', 'Subtitles')} value={showText} onChange={onShowTextChange} />
358
+ <ToggleRow label={t('语音播报', 'TTS')} value={ttsEnabled} onChange={onTtsEnabledChange} />
359
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
360
+ <span style={{ fontSize: 14 }}>{t('音量', 'Volume')}</span>
361
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
362
+ <input
363
+ type="range"
364
+ min={0}
365
+ max={100}
366
+ value={Math.round(volume * 100)}
367
+ onChange={(e) => onVolumeChange(Number(e.target.value) / 100)}
368
+ style={{ width: 100, accentColor: 'rgba(100, 160, 255, 0.8)' }}
369
+ />
370
+ <span style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', width: 28, textAlign: 'right' }}>{Math.round(volume * 100)}</span>
371
+ </div>
372
+ </div>
373
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
374
+ <span style={{ fontSize: 14 }}>{t('视线跟随', 'Eye Tracking')}</span>
375
+ <div style={{ display: 'flex', gap: 4 }}>
376
+ {(['mouse', 'camera'] as const).map((m) => (
377
+ <button
378
+ key={m}
379
+ onClick={() => onTrackingChange(m)}
380
+ style={{
381
+ ...smallBtnStyle,
382
+ background: tracking === m ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.08)',
383
+ borderColor: tracking === m ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
384
+ }}
385
+ >
386
+ {m === 'mouse' ? t('鼠标', 'Mouse') : t('镜头', 'Camera')}
387
+ </button>
388
+ ))}
389
+ </div>
390
+ </div>
391
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
392
+ <span style={{ fontSize: 14 }}>{t('UI位置', 'UI Position')}</span>
393
+ <div style={{ display: 'flex', gap: 4 }}>
394
+ {(['left', 'right'] as const).map((a) => (
395
+ <button
396
+ key={a}
397
+ onClick={() => onUiAlignChange(a)}
398
+ style={{
399
+ ...smallBtnStyle,
400
+ background: uiAlign === a ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.08)',
401
+ borderColor: uiAlign === a ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
402
+ }}
403
+ >
404
+ {a === 'left' ? t('靠左', 'Left') : t('靠右', 'Right')}
405
+ </button>
406
+ ))}
407
+ </div>
408
+ </div>
409
+ <ToggleRow label={t('隐藏UI', 'Hide UI')} value={hideUI} onChange={onHideUIChange} />
410
+ <ToggleRow label={t('隐藏心情条', 'Hide Mood Bar')} value={hideMood} onChange={onHideMoodChange} />
411
+ <ToggleRow label={t('屏幕观察', 'Screen Observe')} value={screenObserve} onChange={onScreenObserveChange} />
412
+ {screenObserve && (
413
+ <>
414
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: -8 }}>
415
+ <span style={{ fontSize: 14 }}>{t('观察间隔', 'Interval')}</span>
416
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
417
+ <input
418
+ type="range"
419
+ min={15}
420
+ max={300}
421
+ step={15}
422
+ value={screenObserveInterval}
423
+ onChange={(e) => onScreenObserveIntervalChange(Number(e.target.value))}
424
+ style={{ width: 100, accentColor: 'rgba(100, 160, 255, 0.8)' }}
425
+ />
426
+ <span style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', width: 36, textAlign: 'right' }}>{screenObserveInterval}s</span>
427
+ </div>
428
+ </div>
429
+
430
+ </>
431
+ )}
432
+ </div>
433
+ )}
434
+
435
+ {tab === 'voice' && (
436
+ <div style={sectionStyle}>
437
+ <div style={labelStyle}>{t('TTS 服务', 'TTS Provider')}</div>
438
+ <div style={{ display: 'flex', gap: 4 }}>
439
+ {(['edge', 'qwen'] as const).map((p) => (
440
+ <button
441
+ key={p}
442
+ onClick={() => setProvider(p)}
443
+ style={{
444
+ ...modelBtnStyle,
445
+ flex: 1,
446
+ textAlign: 'center',
447
+ padding: '6px 10px',
448
+ fontSize: 13,
449
+ background: p === currentProvider ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.08)',
450
+ borderColor: p === currentProvider ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
451
+ }}
452
+ >
453
+ {{ edge: 'Edge', qwen: t('千问 TTS', 'Qwen TTS') }[p]}
454
+ </button>
455
+ ))}
456
+ </div>
457
+
458
+ {currentProvider === 'qwen' && (
459
+ <div style={{ marginTop: 4, display: 'flex', flexDirection: 'column', gap: 8 }}>
460
+ <div>
461
+ <div style={labelStyle}>{t('阿里云 API Key', 'Alibaba Cloud API Key')}</div>
462
+ <input
463
+ type="text"
464
+ value={qwenKey}
465
+ onChange={(e) => setQwenKey(e.target.value)}
466
+ onBlur={() => saveQwenKey(qwenKey)}
467
+ onKeyDown={(e) => { if (e.key === 'Enter') saveQwenKey(qwenKey) }}
468
+ placeholder="sk-..."
469
+ style={{ ...inputStyle, width: '100%' }}
470
+ />
471
+ <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 4 }}>
472
+ {t('从阿里云百炼控制台获取 API Key', 'Get API Key from Alibaba Cloud console')}
473
+ </div>
474
+ </div>
475
+ <div>
476
+ <div style={labelStyle}>{t('语音模型', 'Voice Model')}</div>
477
+ <select
478
+ value={qwenModel}
479
+ onChange={(e) => { setQwenModel(e.target.value); saveQwenModel(e.target.value) }}
480
+ style={selectStyle}
481
+ >
482
+ {QWEN_MODELS.map((m) => (
483
+ <option key={m.id} value={m.id}>{m.label}</option>
484
+ ))}
485
+ </select>
486
+ </div>
487
+ </div>
488
+ )}
489
+
490
+ <div style={{ marginTop: 8 }}>
491
+ <div style={labelStyle}>{currentProvider === 'qwen' ? t('千问语音', 'Qwen Voice') : t('Edge TTS 语音', 'Edge TTS Voice')}</div>
492
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 200, overflowY: 'auto' }}>
493
+ {voices.map((v) => (
494
+ <div
495
+ key={v.id}
496
+ onClick={() => setVoice(v.id)}
497
+ style={{
498
+ ...modelBtnStyle,
499
+ display: 'flex',
500
+ alignItems: 'center',
501
+ justifyContent: 'space-between',
502
+ padding: '6px 6px 6px 10px',
503
+ fontSize: 13,
504
+ background: v.id === currentVoice ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.08)',
505
+ borderColor: v.id === currentVoice ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
506
+ }}
507
+ >
508
+ <div>
509
+ <div>{v.label}</div>
510
+ <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginTop: 1 }}>{v.id}</div>
511
+ </div>
512
+ <div
513
+ onClick={(e) => { e.stopPropagation(); preview(v.id) }}
514
+ style={{
515
+ width: 28,
516
+ height: 28,
517
+ borderRadius: 6,
518
+ display: 'flex',
519
+ alignItems: 'center',
520
+ justifyContent: 'center',
521
+ background: previewingId === v.id ? 'rgba(100, 160, 255, 0.3)' : 'rgba(255, 255, 255, 0.08)',
522
+ cursor: previewingId !== null ? 'default' : 'pointer',
523
+ flexShrink: 0,
524
+ opacity: previewingId !== null && previewingId !== v.id ? 0.3 : 0.7,
525
+ transition: 'background 0.15s',
526
+ }}
527
+ title={t('试听', 'Preview')}
528
+ >
529
+ {previewingId === v.id ? <Loader size={13} style={{ animation: 'spin 1s linear infinite' }} /> : <Play size={13} />}
530
+ </div>
531
+ </div>
532
+ ))}
533
+ </div>
534
+ </div>
535
+ </div>
536
+ )}
537
+
538
+ {tab === 'model' && (
539
+ <div style={sectionStyle}>
540
+ <div style={labelStyle}>{t('内置VRM模型', 'Built-in VRM Models')}</div>
541
+ <select
542
+ value={BUILTIN_MODELS.includes(currentModel) ? currentModel : ''}
543
+ onChange={(e) => { onModelChange(e.target.value); saveModelPath(e.target.value) }}
544
+ style={selectStyle}
545
+ >
546
+ {!BUILTIN_MODELS.includes(currentModel) && <option value="" disabled>{t('未选择', 'Not selected')}</option>}
547
+ {BUILTIN_MODELS.map((m) => (
548
+ <option key={m} value={m}>{m.replace(/^\//, '')}</option>
549
+ ))}
550
+ </select>
551
+
552
+ {models.length > 0 && (
553
+ <div style={{ marginTop: 12 }}>
554
+ <div style={labelStyle}>{t('自定义VRM模型', 'Custom VRM Models')}</div>
555
+ <select
556
+ value={!BUILTIN_MODELS.includes(currentModel) ? currentModel : ''}
557
+ onChange={(e) => { onModelChange(e.target.value); saveModelPath(e.target.value) }}
558
+ style={selectStyle}
559
+ >
560
+ {BUILTIN_MODELS.includes(currentModel) && <option value="" disabled>{t('未选择', 'Not selected')}</option>}
561
+ {models.map((m) => (
562
+ <option key={m} value={m}>{decodeURIComponent(m.split('/').pop() || m)}</option>
563
+ ))}
564
+ </select>
565
+ </div>
566
+ )}
567
+
568
+ <div style={{ marginTop: 12 }}>
569
+ <div style={labelStyle}>{t('导入自定义模型', 'Import Custom Model')}</div>
570
+ <button
571
+ onClick={async () => {
572
+ const file = await pickFile('.vrm,.glb')
573
+ if (!file) return
574
+ try {
575
+ const formData = new FormData()
576
+ formData.append('file', file)
577
+ const res = await fetch(`${FRIEND_API}/model/import`, {
578
+ method: 'POST',
579
+ body: formData,
580
+ })
581
+ const data = await res.json()
582
+ if (data.url) {
583
+ fetch(`${FRIEND_API}/model/list`)
584
+ .then((r) => r.json())
585
+ .then((d) => { if (d.models) setModels(d.models) })
586
+ .catch(() => {})
587
+ onModelChange(data.url)
588
+ saveModelPath(data.url)
589
+ }
590
+ } catch (err) {
591
+ console.warn('Import model failed:', err)
592
+ }
593
+ }}
594
+ style={{ ...applyBtnStyle, width: '100%' }}
595
+ >
596
+ {t('浏览本地文件…', 'Browse local files…')}
597
+ </button>
598
+ <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 4 }}>
599
+ {t('选择本地 .vrm 文件,将保存到工作区 models 目录', 'Select a .vrm file to save to workspace models directory')}
600
+ </div>
601
+ </div>
602
+ </div>
603
+ )}
604
+
605
+ {tab === 'persona' && (
606
+ <div style={sectionStyle}>
607
+ <div style={labelStyle}>IDENTITY.md</div>
608
+ <textarea
609
+ value={identityContent}
610
+ onChange={(e) => { setIdentityContent(e.target.value); setPersonaDirty(true) }}
611
+ placeholder={t('角色身份信息(名字、种族、性格、emoji 等)…', 'Character identity (name, race, personality, emoji, etc.)…')}
612
+ style={textareaStyle}
613
+ rows={4}
614
+ />
615
+
616
+ <div style={{ ...labelStyle, marginTop: 4 }}>SOUL.md</div>
617
+ <textarea
618
+ value={soulContent}
619
+ onChange={(e) => { setSoulContent(e.target.value); setPersonaDirty(true) }}
620
+ placeholder={t('角色灵魂设定(说话风格、行为准则、背景故事等)…', 'Character soul (speaking style, behavior, backstory, etc.)…')}
621
+ style={textareaStyle}
622
+ rows={6}
623
+ />
624
+
625
+ <div style={{ display: 'flex', gap: 4, marginTop: 8 }}>
626
+ <button
627
+ onClick={generatePersona}
628
+ disabled={generating || !captureVrmScreenshot}
629
+ style={{
630
+ ...applyBtnStyle,
631
+ flex: 1,
632
+ display: 'flex',
633
+ alignItems: 'center',
634
+ justifyContent: 'center',
635
+ gap: 4,
636
+ background: generating ? 'rgba(100, 160, 255, 0.3)' : 'rgba(160, 120, 255, 0.5)',
637
+ }}
638
+ title={t('根据当前模型截图自动生成人设', 'Auto-generate persona from model screenshot')}
639
+ >
640
+ {generating
641
+ ? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
642
+ : <Sparkles size={14} />}
643
+ {generating ? t('生成中…', 'Generating…') : t('一键生成人设', 'Auto Generate')}
644
+ </button>
645
+ <button
646
+ onClick={savePersona}
647
+ disabled={!personaDirty || personaSaving}
648
+ style={{
649
+ ...applyBtnStyle,
650
+ opacity: personaDirty ? 1 : 0.4,
651
+ }}
652
+ >
653
+ {personaSaving ? t('保存中…', 'Saving…') : t('保存', 'Save')}
654
+ </button>
655
+ </div>
656
+ <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)', marginTop: 4 }}>
657
+ {t('关联工作区根目录的 IDENTITY.md 和 SOUL.md', 'Linked to IDENTITY.md and SOUL.md in workspace root')}
658
+ </div>
659
+ </div>
660
+ )}
661
+
662
+ {tab === 'dance' && (
663
+ <div style={sectionStyle}>
664
+ <div style={labelStyle}>{t('内置舞蹈', 'Built-in Dances')}</div>
665
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
666
+ {Object.entries(dancePresets).map(([id, preset]) => (
667
+ <div
668
+ key={id}
669
+ onClick={() => onDanceChange(id)}
670
+ style={{
671
+ ...modelBtnStyle,
672
+ display: 'flex',
673
+ alignItems: 'center',
674
+ justifyContent: 'space-between',
675
+ padding: '8px 12px',
676
+ fontSize: 13,
677
+ background: currentDance === id ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.08)',
678
+ borderColor: currentDance === id ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
679
+ }}
680
+ >
681
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
682
+ <Music size={14} style={{ opacity: 0.6 }} />
683
+ <span>{preset.label}</span>
684
+ </div>
685
+ {preset.bgm && (
686
+ <span style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)' }}>{t('含BGM', 'w/ BGM')}</span>
687
+ )}
688
+ </div>
689
+ ))}
690
+ </div>
691
+
692
+ {customDances.length > 0 && (
693
+ <div style={{ marginTop: 12 }}>
694
+ <div style={labelStyle}>{t('自定义舞蹈', 'Custom Dances')}</div>
695
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
696
+ {customDances.map((dance) => (
697
+ <div
698
+ key={dance.id}
699
+ onClick={() => onDanceChange(`custom:${dance.id}`, {
700
+ label: dance.label,
701
+ type: 'vmd',
702
+ url: dance.vmdUrl,
703
+ bgm: dance.bgmUrl,
704
+ })}
705
+ style={{
706
+ ...modelBtnStyle,
707
+ display: 'flex',
708
+ alignItems: 'center',
709
+ justifyContent: 'space-between',
710
+ padding: '8px 10px',
711
+ fontSize: 13,
712
+ background: currentDance === `custom:${dance.id}` ? 'rgba(100, 160, 255, 0.4)' : 'rgba(255, 255, 255, 0.08)',
713
+ borderColor: currentDance === `custom:${dance.id}` ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
714
+ }}
715
+ >
716
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
717
+ <Music size={14} style={{ opacity: 0.6 }} />
718
+ <div>
719
+ <div>{dance.label}</div>
720
+ {dance.bgmUrl && (
721
+ <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 1 }}>{t('含BGM', 'w/ BGM')}</div>
722
+ )}
723
+ </div>
724
+ </div>
725
+ <div
726
+ onClick={(e) => {
727
+ e.stopPropagation()
728
+ fetch(`${FRIEND_API}/dance/delete`, {
729
+ method: 'POST',
730
+ headers: { 'Content-Type': 'application/json' },
731
+ body: JSON.stringify({ id: dance.id }),
732
+ })
733
+ .then(() => {
734
+ fetchCustomDances()
735
+ if (currentDance === `custom:${dance.id}`) onDanceChange('love')
736
+ })
737
+ .catch(() => {})
738
+ }}
739
+ style={{
740
+ width: 28,
741
+ height: 28,
742
+ borderRadius: 6,
743
+ display: 'flex',
744
+ alignItems: 'center',
745
+ justifyContent: 'center',
746
+ background: 'rgba(255, 255, 255, 0.08)',
747
+ cursor: 'pointer',
748
+ flexShrink: 0,
749
+ opacity: 0.5,
750
+ }}
751
+ title={t('删除', 'Delete')}
752
+ >
753
+ <Trash2 size={13} />
754
+ </div>
755
+ </div>
756
+ ))}
757
+ </div>
758
+ </div>
759
+ )}
760
+
761
+ <div style={{ marginTop: 12 }}>
762
+ <div style={labelStyle}>{t('导入自定义舞蹈', 'Import Custom Dance')}</div>
763
+ <button
764
+ disabled={importingDance}
765
+ onClick={async () => {
766
+ setImportingDance(true)
767
+ try {
768
+ // Pick VMD file
769
+ const vmdFile = await pickFile('.vmd')
770
+ if (!vmdFile) { setImportingDance(false); return }
771
+
772
+ // Import VMD
773
+ const vmdFormData = new FormData()
774
+ vmdFormData.append('file', vmdFile)
775
+ const vmdRes = await fetch(`${FRIEND_API}/dance/import`, {
776
+ method: 'POST',
777
+ body: vmdFormData,
778
+ })
779
+ const vmdData = await vmdRes.json()
780
+ if (!vmdData.ok) throw new Error(vmdData.error)
781
+
782
+ // Ask for optional BGM
783
+ const mp3File = await pickFile('.mp3')
784
+ if (mp3File) {
785
+ const mp3FormData = new FormData()
786
+ mp3FormData.append('file', mp3File)
787
+ await fetch(`${FRIEND_API}/dance/import`, {
788
+ method: 'POST',
789
+ body: mp3FormData,
790
+ })
791
+ }
792
+
793
+ fetchCustomDances()
794
+ } catch (err) {
795
+ console.warn('Import dance failed:', err)
796
+ }
797
+ setImportingDance(false)
798
+ }}
799
+ style={{ ...applyBtnStyle, width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
800
+ >
801
+ {importingDance
802
+ ? <Loader size={14} style={{ animation: 'spin 1s linear infinite' }} />
803
+ : <Upload size={14} />}
804
+ {importingDance ? t('导入中…', 'Importing…') : t('选择 VMD 舞蹈文件…', 'Select VMD dance file…')}
805
+ </button>
806
+ <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 4 }}>
807
+ {t('选择 .vmd 舞蹈文件后,可选择配套 .mp3 音乐文件', 'Select a .vmd dance file, then optionally pick a matching .mp3')}
808
+ </div>
809
+ </div>
810
+ </div>
811
+ )}
812
+ </div>
813
+ </div>
814
+ </div>
815
+ )
816
+ }
817
+
818
+ function ToggleRow({ label, value, onChange }: { label: string; value: boolean; onChange: (v: boolean) => void }) {
819
+ return (
820
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
821
+ <span style={{ fontSize: 14 }}>{label}</span>
822
+ <button
823
+ onClick={() => onChange(!value)}
824
+ style={{
825
+ ...toggleStyle,
826
+ background: value ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
827
+ }}
828
+ >
829
+ <div style={{
830
+ ...toggleKnobStyle,
831
+ transform: value ? 'translateX(18px)' : 'translateX(2px)',
832
+ }} />
833
+ </button>
834
+ </div>
835
+ )
836
+ }
837
+
838
+ const overlayStyle: React.CSSProperties = {
839
+ position: 'fixed',
840
+ top: 0,
841
+ left: 0,
842
+ right: 0,
843
+ bottom: 0,
844
+ background: 'rgba(0, 0, 0, 0.5)',
845
+ zIndex: 500,
846
+ display: 'flex',
847
+ alignItems: 'center',
848
+ justifyContent: 'center',
849
+ pointerEvents: 'auto',
850
+ }
851
+
852
+ const panelStyle: React.CSSProperties = {
853
+ width: 320,
854
+ background: 'rgba(30, 30, 40, 0.95)',
855
+ backdropFilter: 'blur(12px)',
856
+ borderRadius: 12,
857
+ border: '1px solid rgba(255, 255, 255, 0.15)',
858
+ boxShadow: '0 8px 32px rgba(0, 0, 0, 0.5)',
859
+ padding: 16,
860
+ color: '#fff',
861
+ fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
862
+ }
863
+
864
+ const headerStyle: React.CSSProperties = {
865
+ display: 'flex',
866
+ justifyContent: 'space-between',
867
+ alignItems: 'center',
868
+ marginBottom: 12,
869
+ cursor: 'grab',
870
+ userSelect: 'none',
871
+ }
872
+
873
+ const closeBtnStyle: React.CSSProperties = {
874
+ width: 28,
875
+ height: 28,
876
+ border: 'none',
877
+ borderRadius: 6,
878
+ background: 'rgba(255, 255, 255, 0.1)',
879
+ color: 'rgba(255, 255, 255, 0.7)',
880
+ cursor: 'pointer',
881
+ display: 'flex',
882
+ alignItems: 'center',
883
+ justifyContent: 'center',
884
+ }
885
+
886
+ const tabBarStyle: React.CSSProperties = {
887
+ display: 'flex',
888
+ gap: 2,
889
+ marginBottom: 16,
890
+ background: 'rgba(255, 255, 255, 0.06)',
891
+ borderRadius: 8,
892
+ padding: 2,
893
+ }
894
+
895
+ const tabStyle: React.CSSProperties = {
896
+ flex: 1,
897
+ height: 32,
898
+ border: 'none',
899
+ borderRadius: 6,
900
+ background: 'transparent',
901
+ color: 'rgba(255, 255, 255, 0.6)',
902
+ fontSize: 13,
903
+ fontWeight: 500,
904
+ cursor: 'pointer',
905
+ }
906
+
907
+ const activeTabStyle: React.CSSProperties = {
908
+ background: 'rgba(255, 255, 255, 0.12)',
909
+ color: '#fff',
910
+ }
911
+
912
+ const contentStyle: React.CSSProperties = {
913
+ minHeight: 120,
914
+ }
915
+
916
+ const sectionStyle: React.CSSProperties = {
917
+ display: 'flex',
918
+ flexDirection: 'column',
919
+ gap: 8,
920
+ }
921
+
922
+ const labelStyle: React.CSSProperties = {
923
+ fontSize: 13,
924
+ color: 'rgba(255, 255, 255, 0.6)',
925
+ marginBottom: 2,
926
+ }
927
+
928
+ const toggleStyle: React.CSSProperties = {
929
+ width: 40,
930
+ height: 22,
931
+ borderRadius: 11,
932
+ border: 'none',
933
+ cursor: 'pointer',
934
+ position: 'relative',
935
+ transition: 'background 0.2s',
936
+ padding: 0,
937
+ }
938
+
939
+ const toggleKnobStyle: React.CSSProperties = {
940
+ width: 18,
941
+ height: 18,
942
+ borderRadius: 9,
943
+ background: '#fff',
944
+ transition: 'transform 0.2s',
945
+ position: 'absolute',
946
+ top: 2,
947
+ }
948
+
949
+ const smallBtnStyle: React.CSSProperties = {
950
+ padding: '4px 10px',
951
+ border: '1px solid',
952
+ borderRadius: 6,
953
+ color: '#fff',
954
+ fontSize: 12,
955
+ cursor: 'pointer',
956
+ }
957
+
958
+ const modelBtnStyle: React.CSSProperties = {
959
+ padding: '8px 12px',
960
+ border: '1px solid',
961
+ borderRadius: 8,
962
+ color: '#fff',
963
+ fontSize: 14,
964
+ cursor: 'pointer',
965
+ textAlign: 'left',
966
+ }
967
+
968
+ const inputStyle: React.CSSProperties = {
969
+ flex: 1,
970
+ height: 32,
971
+ boxSizing: 'border-box',
972
+ border: '1px solid rgba(255, 255, 255, 0.2)',
973
+ borderRadius: 6,
974
+ background: 'rgba(0, 0, 0, 0.3)',
975
+ color: '#fff',
976
+ fontSize: 13,
977
+ padding: '0 8px',
978
+ outline: 'none',
979
+ fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
980
+ }
981
+
982
+ const selectStyle: React.CSSProperties = {
983
+ width: '100%',
984
+ height: 32,
985
+ boxSizing: 'border-box',
986
+ border: '1px solid rgba(255, 255, 255, 0.2)',
987
+ borderRadius: 6,
988
+ background: 'rgba(0, 0, 0, 0.3)',
989
+ color: '#fff',
990
+ fontSize: 13,
991
+ padding: '0 8px',
992
+ outline: 'none',
993
+ fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
994
+ }
995
+
996
+ const textareaStyle: React.CSSProperties = {
997
+ width: '100%',
998
+ boxSizing: 'border-box',
999
+ border: '1px solid rgba(255, 255, 255, 0.2)',
1000
+ borderRadius: 6,
1001
+ background: 'rgba(0, 0, 0, 0.3)',
1002
+ color: '#fff',
1003
+ fontSize: 12,
1004
+ lineHeight: '1.5',
1005
+ padding: '6px 8px',
1006
+ outline: 'none',
1007
+ fontFamily: '"Segoe UI", "Microsoft YaHei", sans-serif',
1008
+ resize: 'vertical',
1009
+ }
1010
+
1011
+ const applyBtnStyle: React.CSSProperties = {
1012
+ height: 32,
1013
+ padding: '0 12px',
1014
+ border: 'none',
1015
+ borderRadius: 6,
1016
+ background: 'rgba(100, 160, 255, 0.5)',
1017
+ color: '#fff',
1018
+ fontSize: 13,
1019
+ cursor: 'pointer',
1020
+ }
friend/frontend/src/components/TextBubble.tsx ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState, useRef, useCallback } from 'react'
2
+ import { FRIEND_API } from '../api'
3
+ import { LipSync } from '../lip-sync'
4
+
5
+ interface VrmMessage {
6
+ text?: string
7
+ emotion?: string
8
+ emotionDuration?: number
9
+ emotionIntensity?: number
10
+ duration?: number
11
+ audioUrl?: string
12
+ audioIndex?: number
13
+ imageUrl?: string
14
+ sendFirstTts?: boolean
15
+ appendText?: boolean
16
+ replyDone?: boolean
17
+ }
18
+
19
+ export type OnVrmMessage = (msg: VrmMessage) => void
20
+
21
+ // CJK detection: check proportion of CJK characters in text
22
+ function cjkRatio(text: string): number {
23
+ const CJK_RE = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef\u3040-\u309f\u30a0-\u30ff]/g
24
+ const matches = text.match(CJK_RE)
25
+ return matches ? matches.length / text.length : 0
26
+ }
27
+
28
+ // Dynamic rate based on CJK proportion
29
+ function getCharRate(text: string, ttsEnabled: boolean): number {
30
+ const ratio = cjkRatio(text)
31
+ if (ttsEnabled) {
32
+ // CJK: 200ms/char, English: 60ms/char, interpolate
33
+ return Math.round(200 * ratio + 60 * (1 - ratio))
34
+ }
35
+ // CJK: 80ms/char, English: 30ms/char, interpolate
36
+ return Math.round(80 * ratio + 30 * (1 - ratio))
37
+ }
38
+ const HIDE_DELAY_MS = 2000 // delay after everything is done before hiding
39
+ const POP_DURATION_MS = 300
40
+
41
+ // Grapheme segmenter singleton
42
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
43
+
44
+ // Keyframes (claw-pop-in) are in index.html <style>
45
+
46
+ export function TextBubble({ onMessage, enabled = true, ttsEnabled = true }: { onMessage?: OnVrmMessage; enabled?: boolean; ttsEnabled?: boolean }) {
47
+ const [text, setText] = useState('')
48
+ const [visible, setVisible] = useState(false)
49
+ const [charCount, setCharCount] = useState(0)
50
+ const [thinking, setThinking] = useState(false)
51
+ const [imageUrl, setImageUrl] = useState<string | null>(null)
52
+ const [zoomedSrc, setZoomedSrc] = useState<string | null>(null)
53
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
54
+ const typewriterRef = useRef<ReturnType<typeof setInterval> | null>(null)
55
+ const scrollRef = useRef<HTMLDivElement>(null)
56
+
57
+ // Split text into grapheme clusters
58
+ const chars = useRef<string[]>([])
59
+ // Current full text (for appending subsequent sentences)
60
+ const fullTextRef = useRef('')
61
+ // Current char rate (for restarting typewriter on appendText)
62
+ const charRateRef = useRef(100)
63
+ // Current charCount as a ref (for restarting typewriter from correct position)
64
+ const charCountRef = useRef(0)
65
+ // Pending text to reveal when a given audio index starts playing
66
+ const pendingTextForAudioRef = useRef<Map<number, string>>(new Map())
67
+
68
+ // Audio queue for sequential playback — keyed by index for ordering
69
+ const audioQueueRef = useRef<Map<number, string>>(new Map())
70
+ const audioPlayingRef = useRef<boolean>(false)
71
+ const audioNextIndexRef = useRef<number>(0)
72
+ const audioReceivedRef = useRef<number>(0)
73
+
74
+ // === sendFirstTts queue ===
75
+ // When a new sendFirstTts arrives while a previous one is still playing,
76
+ // queue it instead of interrupting. Processed when the current reply finishes.
77
+ const pendingSendFirstTtsRef = useRef<VrmMessage[]>([])
78
+ // appendText messages buffered while sendFirstTts is queued
79
+ const pendingAppendRef = useRef<VrmMessage[]>([])
80
+ // replyDone: all sentences dispatched for current reply — required before hide is scheduled
81
+ // Default true so non-sendFirstTts messages (plain text, images) can still schedule hide.
82
+ const replyDoneRef = useRef(true)
83
+ // replyDone arrived while sendFirstTts was still queued — apply after draining queue
84
+ const pendingReplyDoneRef = useRef(false)
85
+
86
+ // Auto-scroll to bottom on char reveal
87
+ useEffect(() => {
88
+ if (scrollRef.current) {
89
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
90
+ }
91
+ }, [charCount])
92
+
93
+ const hideBubble = useCallback(() => {
94
+ setVisible(false)
95
+ setText('')
96
+ setCharCount(0)
97
+ setImageUrl(null)
98
+ setThinking(false)
99
+ chars.current = []
100
+ fullTextRef.current = ''
101
+ charCountRef.current = 0
102
+ pendingTextForAudioRef.current.clear()
103
+ pendingAppendRef.current = []
104
+ replyDoneRef.current = true
105
+ pendingReplyDoneRef.current = false
106
+ if (typewriterRef.current) { clearInterval(typewriterRef.current); typewriterRef.current = null }
107
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
108
+ }, [])
109
+
110
+ // Ref to handleMessage — used by tryScheduleHide to drain the pending queue
111
+ const handleMessageRef = useRef<(msg: VrmMessage) => void>(() => {})
112
+
113
+ // Central function: check if all conditions are met to schedule hide (or drain queue)
114
+ const tryScheduleHide = useCallback(() => {
115
+ // Don't schedule if already scheduled
116
+ if (timerRef.current) return
117
+
118
+ const typewriterDone = typewriterRef.current === null
119
+ const audioDone = !audioPlayingRef.current && audioQueueRef.current.size === 0
120
+
121
+ if (typewriterDone && audioDone) {
122
+ // Check pending sendFirstTts queue before hiding
123
+ if (pendingSendFirstTtsRef.current.length > 0) {
124
+ const next = pendingSendFirstTtsRef.current.shift()!
125
+ handleMessageRef.current(next)
126
+ // Process any buffered appendText messages for this reply
127
+ const appends = pendingAppendRef.current.splice(0)
128
+ for (const a of appends) handleMessageRef.current(a)
129
+ // Apply replyDone that arrived while this reply was queued
130
+ if (pendingReplyDoneRef.current) {
131
+ pendingReplyDoneRef.current = false
132
+ replyDoneRef.current = true
133
+ }
134
+ return
135
+ }
136
+ if (!replyDoneRef.current) return
137
+ timerRef.current = setTimeout(hideBubble, HIDE_DELAY_MS)
138
+ }
139
+ }, [hideBubble])
140
+
141
+ // Interrupt: stop all audio playback and clear queue
142
+ const interruptAudio = useCallback(() => {
143
+ const lipSync = LipSync.getInstance()
144
+ lipSync.stopAudio()
145
+ audioQueueRef.current.clear()
146
+ audioPlayingRef.current = false
147
+ audioNextIndexRef.current = 0
148
+ audioReceivedRef.current = 0
149
+ pendingTextForAudioRef.current.clear()
150
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
151
+ }, [])
152
+
153
+ // Expose interrupt globally for voice call to use
154
+ useEffect(() => {
155
+ (window as any).__clawInterruptAudio = interruptAudio
156
+ return () => { delete (window as any).__clawInterruptAudio }
157
+ }, [interruptAudio])
158
+
159
+ const playNextAudio = useCallback(() => {
160
+ if (audioPlayingRef.current) return
161
+ const nextIdx = audioNextIndexRef.current
162
+ const url = audioQueueRef.current.get(nextIdx)
163
+ if (!url) {
164
+ // Queue empty — check if we can hide
165
+ tryScheduleHide()
166
+ return
167
+ }
168
+ audioQueueRef.current.delete(nextIdx)
169
+ audioNextIndexRef.current = nextIdx + 1
170
+ audioPlayingRef.current = true
171
+
172
+ // Reveal text for this sentence if it arrived via appendText
173
+ const pendingText = pendingTextForAudioRef.current.get(nextIdx)
174
+ if (pendingText !== undefined) {
175
+ pendingTextForAudioRef.current.delete(nextIdx)
176
+ const newGraphemes = [...segmenter.segment(pendingText)].map((s) => s.segment)
177
+ chars.current = [...chars.current, ...newGraphemes]
178
+ fullTextRef.current = fullTextRef.current + pendingText
179
+ setText(fullTextRef.current)
180
+ if (!typewriterRef.current) {
181
+ // Restart typewriter from current position
182
+ let idx = charCountRef.current
183
+ typewriterRef.current = setInterval(() => {
184
+ idx++
185
+ if (idx >= chars.current.length) {
186
+ setCharCount(chars.current.length)
187
+ charCountRef.current = chars.current.length
188
+ clearInterval(typewriterRef.current!); typewriterRef.current = null
189
+ tryScheduleHide()
190
+ } else {
191
+ setCharCount(idx)
192
+ charCountRef.current = idx
193
+ }
194
+ }, charRateRef.current)
195
+ }
196
+ // else: typewriter is running and will pick up new chars via chars.current.length
197
+ }
198
+
199
+ const lipSync = LipSync.getInstance()
200
+ const onDone = () => {
201
+ audioPlayingRef.current = false
202
+ if (audioQueueRef.current.has(audioNextIndexRef.current)) {
203
+ playNextAudio()
204
+ } else {
205
+ // No more queued audio — try to hide (will only succeed if streaming & typewriter also done)
206
+ tryScheduleHide()
207
+ }
208
+ }
209
+ lipSync.playAudio(url).then((durationMs) => {
210
+ setTimeout(onDone, durationMs)
211
+ }).catch(onDone)
212
+ }, [tryScheduleHide])
213
+
214
+ // Use refs for callback dependencies to keep handleMessage stable
215
+ const onMessageRef = useRef(onMessage)
216
+ onMessageRef.current = onMessage
217
+ const ttsEnabledRef = useRef(ttsEnabled)
218
+ ttsEnabledRef.current = ttsEnabled
219
+ const playNextAudioRef = useRef(playNextAudio)
220
+ playNextAudioRef.current = playNextAudio
221
+ const tryScheduleHideRef = useRef(tryScheduleHide)
222
+ tryScheduleHideRef.current = tryScheduleHide
223
+ const hideBubbleRef = useRef(hideBubble)
224
+ hideBubbleRef.current = hideBubble
225
+ const thinkingRef = useRef(thinking)
226
+ thinkingRef.current = thinking
227
+ const visibleRef = useRef(visible)
228
+ visibleRef.current = visible
229
+
230
+ // Watchdog: if bubble is stuck (no audio, no typewriter, no queued work), force-hide after 30s
231
+ useEffect(() => {
232
+ const id = setInterval(() => {
233
+ if (!visibleRef.current) return
234
+ if (audioPlayingRef.current) return
235
+ if (typewriterRef.current !== null) return
236
+ if (audioQueueRef.current.size > 0) return
237
+ if (pendingSendFirstTtsRef.current.length > 0) return
238
+ if (timerRef.current !== null) return // hide already scheduled
239
+ console.warn('[claw-sama] watchdog: bubble stuck, forcing hide')
240
+ hideBubbleRef.current()
241
+ }, 30_000)
242
+ return () => clearInterval(id)
243
+ }, [])
244
+
245
+ // Stable handleMessage — never causes SSE reconnect
246
+ const handleMessage = useCallback((msg: VrmMessage) => {
247
+ // Audio-only message (legacy path, kept for compatibility)
248
+ if (!msg.text && msg.audioUrl && !msg.appendText) {
249
+ audioReceivedRef.current++
250
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
251
+ const idx = msg.audioIndex ?? audioReceivedRef.current - 1
252
+ audioQueueRef.current.set(idx, msg.audioUrl)
253
+ playNextAudioRef.current()
254
+ return
255
+ }
256
+
257
+ // appendText: subsequent sentence with matching audio — queue both together
258
+ if (msg.appendText && msg.text) {
259
+ // If sendFirstTts is queued (previous reply still playing), buffer this too
260
+ if (pendingSendFirstTtsRef.current.length > 0 || audioPlayingRef.current) {
261
+ if (pendingSendFirstTtsRef.current.length > 0) {
262
+ pendingAppendRef.current.push(msg)
263
+ return
264
+ }
265
+ }
266
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
267
+ const idx = msg.audioIndex ?? 0
268
+ // Store text to reveal when this audio index starts playing
269
+ pendingTextForAudioRef.current.set(idx, msg.text)
270
+ if (msg.audioUrl) {
271
+ audioQueueRef.current.set(idx, msg.audioUrl)
272
+ playNextAudioRef.current()
273
+ }
274
+ return
275
+ }
276
+
277
+ // Image-only message
278
+ if (!msg.text && msg.imageUrl) {
279
+ setText('')
280
+ setCharCount(0)
281
+ if (typewriterRef.current) { clearInterval(typewriterRef.current); typewriterRef.current = null }
282
+ setImageUrl(msg.imageUrl)
283
+ setVisible(true)
284
+ if (timerRef.current) clearTimeout(timerRef.current)
285
+ timerRef.current = setTimeout(() => hideBubbleRef.current(), 15_000)
286
+ return
287
+ }
288
+
289
+ // Emotion-only message
290
+ if (!msg.text) {
291
+ if (msg.emotion) {
292
+ onMessageRef.current?.({ ...msg, emotionDuration: msg.emotionDuration ?? 10000 })
293
+ if (thinkingRef.current) {
294
+ setThinking(false)
295
+ hideBubbleRef.current()
296
+ }
297
+ }
298
+ return
299
+ }
300
+
301
+ // --- Text message ---
302
+
303
+ // Cancel pending hide — new text arrived
304
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
305
+ if (typewriterRef.current) { clearInterval(typewriterRef.current); typewriterRef.current = null }
306
+
307
+ // sendFirstTts: queue if something is currently playing, otherwise reset and play
308
+ if (msg.sendFirstTts) {
309
+ const busy = audioPlayingRef.current || typewriterRef.current !== null
310
+ if (busy) {
311
+ pendingSendFirstTtsRef.current.push(msg)
312
+ return
313
+ }
314
+ replyDoneRef.current = false
315
+ const lipSync = LipSync.getInstance()
316
+ lipSync.stopAudio()
317
+ // Preserve early-arriving audio and text for this new reply (index >= 1)
318
+ const earlyAudio = new Map<number, string>()
319
+ for (const [idx, url] of audioQueueRef.current) {
320
+ if (idx >= 1) earlyAudio.set(idx, url)
321
+ }
322
+ const earlyText = new Map<number, string>()
323
+ for (const [idx, t] of pendingTextForAudioRef.current) {
324
+ if (idx >= 1) earlyText.set(idx, t)
325
+ }
326
+ audioQueueRef.current.clear()
327
+ pendingTextForAudioRef.current.clear()
328
+ if (msg.audioUrl) audioQueueRef.current.set(0, msg.audioUrl)
329
+ for (const [idx, url] of earlyAudio) audioQueueRef.current.set(idx, url)
330
+ for (const [idx, t] of earlyText) pendingTextForAudioRef.current.set(idx, t)
331
+ audioPlayingRef.current = false
332
+ audioNextIndexRef.current = 0
333
+ audioReceivedRef.current = 0
334
+ } else {
335
+ audioReceivedRef.current = 0
336
+ audioNextIndexRef.current = 0
337
+ }
338
+
339
+ const fullText = msg.text!
340
+ const graphemes = [...segmenter.segment(fullText)].map((s) => s.segment)
341
+ chars.current = graphemes
342
+ fullTextRef.current = fullText
343
+ charCountRef.current = 0
344
+
345
+ setText(fullText)
346
+ setThinking(false)
347
+ setVisible(true)
348
+ setCharCount(0)
349
+
350
+ const baseRate = getCharRate(fullText, ttsEnabledRef.current)
351
+ charRateRef.current = baseRate
352
+
353
+ if (graphemes.length === 0) {
354
+ tryScheduleHideRef.current()
355
+ } else {
356
+ const emotionDuration = graphemes.length * baseRate + 5000
357
+ setTimeout(() => onMessageRef.current?.({ ...msg, emotionDuration }), 1000)
358
+
359
+ let idx = 0
360
+ typewriterRef.current = setInterval(() => {
361
+ idx++
362
+ if (idx >= chars.current.length) {
363
+ setCharCount(chars.current.length)
364
+ charCountRef.current = chars.current.length
365
+ if (typewriterRef.current) { clearInterval(typewriterRef.current); typewriterRef.current = null }
366
+ tryScheduleHideRef.current()
367
+ } else {
368
+ setCharCount(idx)
369
+ charCountRef.current = idx
370
+ }
371
+ }, baseRate)
372
+ }
373
+
374
+ // All audio goes through the queue via playNextAudio.
375
+ if (msg.sendFirstTts && ttsEnabledRef.current) {
376
+ playNextAudioRef.current()
377
+ }
378
+ }, []) // stable — no deps, uses refs for everything
379
+
380
+ // Keep handleMessageRef in sync so tryScheduleHide can drain the queue
381
+ handleMessageRef.current = handleMessage
382
+
383
+ useEffect(() => {
384
+ const es = new EventSource(`${FRIEND_API}/events`)
385
+ es.onmessage = (e) => {
386
+ try {
387
+ const data = JSON.parse(e.data)
388
+ if (data.clearText) {
389
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null }
390
+ if (typewriterRef.current) { clearInterval(typewriterRef.current); typewriterRef.current = null }
391
+ setText('')
392
+ setCharCount(0)
393
+ setThinking(false)
394
+ setVisible(false)
395
+ setImageUrl(null)
396
+ audioQueueRef.current.clear()
397
+ audioPlayingRef.current = false
398
+ audioNextIndexRef.current = 0
399
+ audioReceivedRef.current = 0
400
+ pendingTextForAudioRef.current.clear()
401
+ pendingSendFirstTtsRef.current = []
402
+ pendingAppendRef.current = []
403
+ replyDoneRef.current = true
404
+ pendingReplyDoneRef.current = false
405
+ return
406
+ }
407
+ if (data.replyDone) {
408
+ if (pendingSendFirstTtsRef.current.length > 0) {
409
+ // sendFirstTts for this reply is still queued — buffer replyDone until queue drains
410
+ pendingReplyDoneRef.current = true
411
+ } else {
412
+ replyDoneRef.current = true
413
+ tryScheduleHideRef.current()
414
+ }
415
+ return
416
+ }
417
+ const msg: VrmMessage = data
418
+ handleMessage(msg)
419
+ } catch { /* ignore malformed */ }
420
+ }
421
+ return () => {
422
+ es.close()
423
+ if (timerRef.current) clearTimeout(timerRef.current)
424
+ if (typewriterRef.current) clearInterval(typewriterRef.current)
425
+ }
426
+ }, [handleMessage])
427
+
428
+ const showBubble = enabled && visible && (!!text || !!imageUrl)
429
+
430
+ return (
431
+ <>
432
+ {showBubble && (
433
+ <div style={containerStyle}>
434
+ <div ref={scrollRef} style={boxStyle} data-no-passthrough>
435
+ {imageUrl && (
436
+ <img
437
+ src={imageUrl}
438
+ alt=""
439
+ style={imageThumbStyle}
440
+ onClick={() => setZoomedSrc(imageUrl)}
441
+ onError={() => setImageUrl(null)}
442
+ />
443
+ )}
444
+ {text && (
445
+ <div style={textStyle}>
446
+ {chars.current.map((ch, i) => (
447
+ i < charCount ? (
448
+ <span key={i} style={popCharStyle}>{ch === '\n' ? <br /> : ch}</span>
449
+ ) : null
450
+ ))}
451
+ </div>
452
+ )}
453
+ </div>
454
+ </div>
455
+ )}
456
+ {zoomedSrc && (
457
+ <div style={overlayStyle} data-no-passthrough>
458
+ <div style={{ position: 'relative', display: 'inline-block', maxWidth: '90%', maxHeight: '90%' }}>
459
+ <img
460
+ src={zoomedSrc}
461
+ alt=""
462
+ style={zoomedImageStyle}
463
+ onError={() => setZoomedSrc(null)}
464
+ />
465
+ <button
466
+ style={closeButtonStyle}
467
+ onClick={() => setZoomedSrc(null)}
468
+ >
469
+
470
+ </button>
471
+ </div>
472
+ </div>
473
+ )}
474
+ </>
475
+ )
476
+ }
477
+
478
+ const imageThumbStyle: React.CSSProperties = {
479
+ maxWidth: '80%',
480
+ maxHeight: 120,
481
+ borderRadius: 6,
482
+ border: '1px solid rgba(255, 255, 255, 0.3)',
483
+ objectFit: 'contain' as const,
484
+ marginBottom: 4,
485
+ display: 'block',
486
+ cursor: 'pointer',
487
+ }
488
+
489
+ const overlayStyle: React.CSSProperties = {
490
+ position: 'fixed',
491
+ top: 0,
492
+ left: 0,
493
+ right: 0,
494
+ bottom: 0,
495
+ background: 'rgba(0, 0, 0, 0.7)',
496
+ backdropFilter: 'blur(8px)',
497
+ display: 'flex',
498
+ alignItems: 'center',
499
+ justifyContent: 'center',
500
+ zIndex: 10000,
501
+ pointerEvents: 'auto',
502
+ }
503
+
504
+ const closeButtonStyle: React.CSSProperties = {
505
+ position: 'absolute',
506
+ top: 8,
507
+ right: 8,
508
+ width: 32,
509
+ height: 32,
510
+ border: 'none',
511
+ borderRadius: 6,
512
+ background: 'rgba(125, 125, 125, 0.28)',
513
+ backdropFilter: 'blur(6px)',
514
+ color: 'rgba(255, 255, 255, 0.8)',
515
+ fontSize: 16,
516
+ cursor: 'pointer',
517
+ display: 'flex',
518
+ alignItems: 'center',
519
+ justifyContent: 'center',
520
+ zIndex: 10001,
521
+ }
522
+
523
+ const zoomedImageStyle: React.CSSProperties = {
524
+ maxWidth: '100%',
525
+ maxHeight: '100%',
526
+ borderRadius: 8,
527
+ border: '1px solid rgba(255, 255, 255, 0.3)',
528
+ boxShadow: '0 0 24px rgba(100, 160, 255, 0.4)',
529
+ objectFit: 'contain' as const,
530
+ display: 'block',
531
+ }
532
+
533
+ const popCharStyle: React.CSSProperties = {
534
+ display: 'inline-block',
535
+ animation: `claw-pop-in ${POP_DURATION_MS}ms ease-out both`,
536
+ whiteSpace: 'pre',
537
+ }
538
+
539
+ const containerStyle: React.CSSProperties = {
540
+ position: 'absolute',
541
+ bottom: 80,
542
+ left: 0,
543
+ width: '100%',
544
+ zIndex: 200,
545
+ pointerEvents: 'none',
546
+ padding: 8,
547
+ boxSizing: 'border-box',
548
+ }
549
+
550
+ const boxStyle: React.CSSProperties = {
551
+ background: 'rgba(0, 0, 0, 0.35)',
552
+ backdropFilter: 'blur(6px)',
553
+ borderRadius: 12,
554
+ border: '1px solid rgba(255, 255, 255, 0.15)',
555
+ boxShadow: '0 0 12px rgba(100, 160, 255, 0.25), 0 0 24px rgba(100, 160, 255, 0.1)',
556
+ padding: '8px 12px',
557
+ height: 140,
558
+ overflowY: 'auto' as const,
559
+ pointerEvents: 'auto',
560
+ userSelect: 'text',
561
+ cursor: 'text',
562
+ }
563
+
564
+ const textStyle: React.CSSProperties = {
565
+ color: '#fff',
566
+ fontSize: 22,
567
+ lineHeight: 1.6,
568
+ wordBreak: 'break-word',
569
+ fontFamily: '"Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif',
570
+ textShadow: '0 0 6px rgba(255,255,255,0.5)',
571
+ }
friend/frontend/src/components/VRMScene.tsx ADDED
@@ -0,0 +1,870 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useCallback } from 'react'
2
+ import * as THREE from 'three'
3
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
4
+ import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'
5
+ import { VRMLookAtQuaternionProxy } from '@pixiv/three-vrm-animation'
6
+ import type { VRM } from '@pixiv/three-vrm'
7
+ import { EmoteController } from '../emote'
8
+ import { LipSync } from '../lip-sync'
9
+ import { MotionController } from '../motion-controller'
10
+
11
+ export type TouchRegion = 'head' | 'arm' | 'leg' | 'chest' | 'belly' | 'buttocks'
12
+
13
+ interface VRMSceneProps {
14
+ modelPath: string
15
+ idleAnimationPath?: string
16
+ onTouch?: (region: TouchRegion) => void
17
+ onModelLoaded?: () => void
18
+ }
19
+
20
+ export type TrackingMode = 'mouse' | 'camera'
21
+
22
+ export interface VRMSceneHandle {
23
+ setEmotion: (emotion: string, intensity?: number) => void
24
+ setEmotionWithReset: (emotion: string, durationMs: number, intensity?: number) => void
25
+ resetCamera: () => void
26
+ setTrackingMode: (mode: TrackingMode) => void
27
+ playAction: (name: string, hold?: boolean) => void
28
+ captureScreenshot: () => string | null
29
+ panCamera: (dx: number, dy: number) => void
30
+ rotateCamera: (dx: number, dy: number) => void
31
+ playDance: (nameOrPreset: string | import('../motion-controller').DancePreset) => void
32
+ stopDance: () => void
33
+ isDancing: () => boolean
34
+ setBgmVolume: (v: number) => void
35
+ /** Unified reset: camera + resetToIdle + expressions to zero */
36
+ reset: () => void
37
+ }
38
+
39
+ // ── Blink state ───────────────────────────────────────────────────────────────
40
+ interface BlinkState {
41
+ isBlinking: boolean
42
+ blinkProgress: number
43
+ timeSinceLastBlink: number
44
+ nextBlinkTime: number
45
+ }
46
+
47
+ function createBlinkState(): BlinkState {
48
+ return {
49
+ isBlinking: false,
50
+ blinkProgress: 0,
51
+ timeSinceLastBlink: 0,
52
+ nextBlinkTime: Math.random() * 4 + 1,
53
+ }
54
+ }
55
+
56
+ function updateBlink(vrm: VRM, delta: number, state: BlinkState) {
57
+ if (!vrm.expressionManager) return
58
+
59
+ state.timeSinceLastBlink += delta
60
+
61
+ if (!state.isBlinking && state.timeSinceLastBlink >= state.nextBlinkTime) {
62
+ state.isBlinking = true
63
+ state.blinkProgress = 0
64
+ }
65
+
66
+ if (state.isBlinking) {
67
+ const BLINK_DURATION = 0.15
68
+ state.blinkProgress += delta / BLINK_DURATION
69
+ const blinkValue = Math.sin(Math.PI * state.blinkProgress)
70
+ vrm.expressionManager.setValue('blink', blinkValue)
71
+
72
+ if (state.blinkProgress >= 1) {
73
+ state.isBlinking = false
74
+ state.timeSinceLastBlink = 0
75
+ vrm.expressionManager.setValue('blink', 0)
76
+ state.nextBlinkTime = Math.random() * 5 + 1
77
+ }
78
+ }
79
+ }
80
+
81
+ // ── Relaxed hand pose ─────────────────────────────────────────────────────────
82
+ // The idle_loop.vrma often has no finger tracks, so fingers stay in the stiff
83
+ // T-pose. This must be applied EVERY FRAME after mixer.update() because the
84
+ // AnimationMixer resets bones that have no tracks back to their rest rotation.
85
+ //
86
+ // VRM normalized bones use Z-axis for finger curl (spread is Y-axis).
87
+ // Left hand curls positive Z, right hand curls negative Z.
88
+ // We also add slight spread (Y-axis) variation per finger for a natural look,
89
+ // and subtle per-frame micro-movement to avoid a "frozen" appearance.
90
+
91
+ interface HandPoseCache {
92
+ bones: { bone: THREE.Object3D; z: number; y: number }[]
93
+ }
94
+
95
+ function buildHandPoseCache(vrm: VRM): HandPoseCache {
96
+ const humanoid = vrm.humanoid
97
+ const bones: HandPoseCache['bones'] = []
98
+ if (!humanoid) return { bones }
99
+
100
+ const fingers = ['Thumb', 'Index', 'Middle', 'Ring', 'Little'] as const
101
+ const segments = ['Proximal', 'Intermediate', 'Distal'] as const
102
+ const sides = ['left', 'right'] as const
103
+
104
+ // Base curl values — outer fingers curl more for a natural resting hand
105
+ const curlMap: Record<string, [number, number, number]> = {
106
+ Thumb: [0.25, 0.15, 0.10],
107
+ Index: [0.20, 0.30, 0.20],
108
+ Middle: [0.25, 0.35, 0.25],
109
+ Ring: [0.30, 0.40, 0.30],
110
+ Little: [0.35, 0.45, 0.30],
111
+ }
112
+
113
+ // Slight spread (Y-axis) to fan fingers apart naturally
114
+ const spreadMap: Record<string, number> = {
115
+ Thumb: 0.15,
116
+ Index: 0.04,
117
+ Middle: 0.0,
118
+ Ring: -0.04,
119
+ Little: -0.08,
120
+ }
121
+
122
+ for (const side of sides) {
123
+ const sign = side === 'left' ? 1 : -1
124
+
125
+ for (const finger of fingers) {
126
+ const curls = curlMap[finger]
127
+ const spread = spreadMap[finger]
128
+
129
+ for (let s = 0; s < segments.length; s++) {
130
+ const boneName = `${side}${finger}${segments[s]}` as any
131
+ const bone = humanoid.getNormalizedBoneNode(boneName)
132
+ if (!bone) continue
133
+
134
+ const z = sign * curls[s]
135
+ // Only apply spread on the proximal segment
136
+ const y = s === 0 ? sign * spread : 0
137
+
138
+ bones.push({ bone, z, y })
139
+ }
140
+ }
141
+ }
142
+
143
+ return { bones }
144
+ }
145
+
146
+ function applyRelaxedHandPose(cache: HandPoseCache, time: number) {
147
+ for (const { bone, z, y } of cache.bones) {
148
+ // Subtle micro-movement: ±0.02 rad oscillation at slightly different
149
+ // frequencies per bone (seeded by the base z value) to avoid uniformity
150
+ const freq = 0.3 + Math.abs(z) * 2
151
+ const micro = Math.sin(time * freq + z * 50) * 0.02
152
+ bone.rotation.z = z + micro
153
+ if (y !== 0) bone.rotation.y = y
154
+ }
155
+ }
156
+
157
+
158
+ export const VRMScene = forwardRef<VRMSceneHandle, VRMSceneProps>(function VRMScene({
159
+ modelPath,
160
+ idleAnimationPath = '/friend/idle_loop.vrma',
161
+ onTouch,
162
+ onModelLoaded,
163
+ }, ref) {
164
+ const canvasRef = useRef<HTMLCanvasElement>(null)
165
+ const [ripples, setRipples] = useState<{ id: number; x: number; y: number; confirmed: boolean }[]>([])
166
+ const rippleIdRef = useRef(0)
167
+
168
+ const spawnRipple = useCallback((x: number, y: number, confirmed: boolean) => {
169
+ const id = ++rippleIdRef.current
170
+ setRipples(prev => [...prev, { id, x, y, confirmed }])
171
+ setTimeout(() => setRipples(prev => prev.filter(r => r.id !== id)), confirmed ? 700 : 500)
172
+ }, [])
173
+
174
+ const emoteRef = useRef<EmoteController | null>(null)
175
+ const resetCameraRef = useRef<(() => void) | null>(null)
176
+ const trackingModeRef = useRef<TrackingMode>('mouse')
177
+ const motionRef = useRef<MotionController | null>(null)
178
+ const panCameraRef = useRef<((dx: number, dy: number) => void) | null>(null)
179
+ const rotateCameraRef = useRef<((dx: number, dy: number) => void) | null>(null)
180
+ const lipSyncRef = useRef<LipSync>(LipSync.getInstance())
181
+ const onTouchRef = useRef(onTouch)
182
+ onTouchRef.current = onTouch
183
+ const onModelLoadedRef = useRef(onModelLoaded)
184
+ onModelLoadedRef.current = onModelLoaded
185
+
186
+ useImperativeHandle(ref, () => ({
187
+ setEmotion(emotion: string, intensity?: number) {
188
+ emoteRef.current?.setEmotion(emotion, intensity)
189
+ },
190
+ setEmotionWithReset(emotion: string, durationMs: number, intensity?: number) {
191
+ emoteRef.current?.setEmotionWithReset(emotion, durationMs, intensity)
192
+ },
193
+ resetCamera() {
194
+ resetCameraRef.current?.()
195
+ },
196
+ setTrackingMode(mode: TrackingMode) {
197
+ trackingModeRef.current = mode
198
+ },
199
+ playAction(name: string, hold?: boolean) {
200
+ motionRef.current?.playAction(name, hold)
201
+ },
202
+ captureScreenshot() {
203
+ return canvasRef.current?.toDataURL('image/png') ?? null
204
+ },
205
+ panCamera(dx: number, dy: number) {
206
+ panCameraRef.current?.(dx, dy)
207
+ },
208
+ rotateCamera(dx: number, dy: number) {
209
+ rotateCameraRef.current?.(dx, dy)
210
+ },
211
+ playDance(nameOrPreset: string | import('../motion-controller').DancePreset) {
212
+ motionRef.current?.playDance(nameOrPreset)
213
+ },
214
+ stopDance() {
215
+ motionRef.current?.resetToIdle()
216
+ },
217
+ isDancing() {
218
+ return motionRef.current?.isDancing ?? false
219
+ },
220
+ setBgmVolume(v: number) {
221
+ motionRef.current?.setVolume(v)
222
+ },
223
+ reset() {
224
+ resetCameraRef.current?.()
225
+ motionRef.current?.resetToIdle()
226
+ emoteRef.current?.resetAll()
227
+ },
228
+ }))
229
+
230
+ useEffect(() => {
231
+ const canvas = canvasRef.current
232
+ if (!canvas) return
233
+
234
+ // ── Renderer ──────────────────────────────────────────────────────────────
235
+ const renderer = new THREE.WebGLRenderer({
236
+ canvas,
237
+ alpha: true,
238
+ antialias: true,
239
+ preserveDrawingBuffer: true,
240
+ })
241
+ renderer.setSize(window.innerWidth, window.innerHeight)
242
+ renderer.setPixelRatio(window.devicePixelRatio)
243
+ renderer.setClearColor(0x000000, 0)
244
+
245
+ // ── Scene ─────────────────────────────────────────────────────────────────
246
+ const scene = new THREE.Scene()
247
+
248
+ // ── Camera ────────────────────────────────────────────────────────────────
249
+ const FOV = 40
250
+ const camera = new THREE.PerspectiveCamera(
251
+ FOV,
252
+ window.innerWidth / window.innerHeight,
253
+ 0.1,
254
+ 100,
255
+ )
256
+ // Orbit state: camera orbits around the pivot point
257
+ // Will be recalculated after model loads (like airi)
258
+ const pivot = new THREE.Vector3(0, 0, 0)
259
+ let orbitRadius = 2.0
260
+ let orbitTheta = 0 // horizontal angle (radians)
261
+ let orbitPhi = Math.PI / 2 // vertical angle (radians), PI/2 = eye level
262
+
263
+ function updateCameraOrbit() {
264
+ camera.position.set(
265
+ pivot.x + orbitRadius * Math.sin(orbitPhi) * Math.sin(orbitTheta),
266
+ pivot.y + orbitRadius * Math.cos(orbitPhi),
267
+ pivot.z + orbitRadius * Math.sin(orbitPhi) * Math.cos(orbitTheta),
268
+ )
269
+ camera.lookAt(pivot)
270
+ }
271
+ updateCameraOrbit()
272
+
273
+ // ── Lights ────────────────────────────────────────────────────────────────
274
+ scene.add(new THREE.AmbientLight(0xffffff, 0.6))
275
+ const dirLight = new THREE.DirectionalLight(0xffffff, 1.2)
276
+ dirLight.position.set(1, 2, 3)
277
+ scene.add(dirLight)
278
+ const fillLight = new THREE.DirectionalLight(0xffffff, 0.4)
279
+ fillLight.position.set(-2, 1, -1)
280
+ scene.add(fillLight)
281
+
282
+ // ── Loader ───────────────────────────────────────────────────────────────
283
+ const loader = new GLTFLoader()
284
+ loader.register((parser) => new VRMLoaderPlugin(parser))
285
+
286
+ // ── State ─────────────────────────────────────────────────────────────────
287
+ let vrm: VRM | null = null
288
+ let motion: MotionController | null = null
289
+ let emote: EmoteController | null = null
290
+ let handPose: HandPoseCache | null = null
291
+ const blinkState = createBlinkState()
292
+ const saccades = new EyeSaccadeController()
293
+ const lookAtTarget = { x: 0, y: 0, z: -100 }
294
+
295
+ // ── Load VRM model, then load idle animation ─────────────────────────────
296
+ loader.load(
297
+ modelPath,
298
+ async (gltf) => {
299
+ const loadedVrm = gltf.userData.vrm as VRM
300
+ if (!loadedVrm) {
301
+ console.error('No VRM data found in GLTF')
302
+ return
303
+ }
304
+
305
+ VRMUtils.removeUnnecessaryVertices(loadedVrm.scene)
306
+ VRMUtils.combineSkeletons(loadedVrm.scene)
307
+ loadedVrm.scene.traverse((obj) => {
308
+ obj.frustumCulled = false
309
+ })
310
+
311
+ // Add lookAt quaternion proxy (from airi — needed for lookAt to work)
312
+ if (loadedVrm.lookAt) {
313
+ const lookAtQuatProxy = new VRMLookAtQuaternionProxy(loadedVrm.lookAt)
314
+ lookAtQuatProxy.name = 'lookAtQuaternionProxy'
315
+ loadedVrm.scene.add(lookAtQuatProxy)
316
+ }
317
+
318
+ // Normalize VRM 0.x to match 1.0 convention, then face camera
319
+ VRMUtils.rotateVRM0(loadedVrm)
320
+
321
+ scene.add(loadedVrm.scene)
322
+ vrm = loadedVrm
323
+
324
+ // ── Compute camera from model bounds (airi style) ───────────────────
325
+ const box = new THREE.Box3().setFromObject(loadedVrm.scene)
326
+ const modelSize = new THREE.Vector3()
327
+ const modelCenter = new THREE.Vector3()
328
+ box.getSize(modelSize)
329
+ box.getCenter(modelCenter)
330
+ modelCenter.y += modelSize.y / 3.2 // pivot at neck height
331
+
332
+ const radians = (FOV / 2 * Math.PI) / 180
333
+ const offsetX = modelSize.x / 16
334
+ const offsetY = modelSize.y / 10
335
+ const offsetZ = (modelSize.y / 4.2) / Math.tan(radians)
336
+
337
+ pivot.copy(modelCenter)
338
+ orbitRadius = offsetZ
339
+ orbitTheta = Math.atan2(offsetX, offsetZ)
340
+ orbitPhi = Math.PI / 2 - Math.atan2(offsetY, offsetZ)
341
+ updateCameraOrbit()
342
+
343
+ // Store initial state for reset
344
+ const initPivot = pivot.clone()
345
+ const initRadius = orbitRadius
346
+ const initTheta = orbitTheta
347
+ const initPhi = orbitPhi
348
+ resetCameraRef.current = () => {
349
+ pivot.copy(initPivot)
350
+ orbitRadius = initRadius
351
+ orbitTheta = initTheta
352
+ orbitPhi = initPhi
353
+ updateCameraOrbit()
354
+ }
355
+
356
+ panCameraRef.current = (dx: number, dy: number) => {
357
+ const right = new THREE.Vector3()
358
+ const up = new THREE.Vector3()
359
+ camera.getWorldDirection(new THREE.Vector3())
360
+ right.setFromMatrixColumn(camera.matrixWorld, 0) // camera right
361
+ up.setFromMatrixColumn(camera.matrixWorld, 1) // camera up
362
+ pivot.addScaledVector(right, -dx * 0.003)
363
+ pivot.addScaledVector(up, dy * 0.003)
364
+ updateCameraOrbit()
365
+ }
366
+
367
+ rotateCameraRef.current = (dx: number, dy: number) => {
368
+ orbitTheta -= dx * 0.005
369
+ orbitPhi = THREE.MathUtils.clamp(
370
+ orbitPhi - dy * 0.005,
371
+ 0.1,
372
+ Math.PI - 0.1,
373
+ )
374
+ updateCameraOrbit()
375
+ }
376
+
377
+ // Build hand pose cache (applied every frame in animate loop)
378
+ handPose = buildHandPoseCache(loadedVrm)
379
+
380
+ // Initialize emote controller
381
+ emote = new EmoteController(loadedVrm)
382
+ emoteRef.current = emote
383
+
384
+ // ── Initialize MotionController ──────────────────────────────────────
385
+ motion = new MotionController(loadedVrm)
386
+ motionRef.current = motion
387
+
388
+ // Dance camera: auto-fit from hips position, centered on upper body
389
+ const danceRadius = (modelSize.y / 1.6) / Math.tan((FOV / 2 * Math.PI) / 180)
390
+
391
+ motion.onDanceStart = () => {
392
+ const hipsNode = loadedVrm.humanoid?.getNormalizedBoneNode('hips')
393
+ if (hipsNode) {
394
+ const hipsWorld = new THREE.Vector3()
395
+ hipsNode.getWorldPosition(hipsWorld)
396
+ // Pivot at hips height (upper body center)
397
+ pivot.set(hipsWorld.x, hipsWorld.y, hipsWorld.z)
398
+ } else {
399
+ pivot.copy(modelCenter)
400
+ }
401
+ orbitRadius = danceRadius
402
+ orbitTheta = 0
403
+ orbitPhi = Math.PI / 2
404
+ updateCameraOrbit()
405
+ }
406
+ motion.onDanceStop = () => {
407
+ pivot.copy(initPivot)
408
+ orbitRadius = initRadius
409
+ orbitTheta = initTheta
410
+ orbitPhi = initPhi
411
+ updateCameraOrbit()
412
+ }
413
+
414
+ // Load idle animation (non-blocking for fast startup)
415
+ motion.loadIdle(idleAnimationPath).catch((err) =>
416
+ console.warn('Failed to load idle animation:', err),
417
+ )
418
+
419
+ // Reset spring bones after everything is set up
420
+ loadedVrm.springBoneManager?.reset()
421
+
422
+ // Notify parent that model is ready (for auto-screenshot etc.)
423
+ // Delay slightly so the first frame is rendered
424
+ setTimeout(() => onModelLoadedRef.current?.(), 500)
425
+ },
426
+ () => {},
427
+ (err) => {
428
+ console.error('Failed to load VRM:', err)
429
+ },
430
+ )
431
+
432
+ // ── Mouse tracking ────────────────────────────────────────────────────────
433
+ const mouse = new THREE.Vector2(0, 0)
434
+
435
+ const _raycaster = new THREE.Raycaster()
436
+ const _mouseVec = new THREE.Vector2()
437
+
438
+ function onMouseMove(e: MouseEvent) {
439
+ mouse.x = (e.clientX / window.innerWidth) * 2 - 1
440
+ mouse.y = -(e.clientY / window.innerHeight) * 2 + 1
441
+
442
+ if (trackingModeRef.current !== 'mouse') return
443
+
444
+ // Compute lookAt target like airi's lookAtMouse
445
+ _mouseVec.set(mouse.x, mouse.y)
446
+ _raycaster.setFromCamera(_mouseVec, camera)
447
+ const camDir = new THREE.Vector3()
448
+ camera.getWorldDirection(camDir)
449
+ const plane = new THREE.Plane()
450
+ plane.setFromNormalAndCoplanarPoint(
451
+ camDir,
452
+ camera.position.clone().add(camDir.multiplyScalar(1)),
453
+ )
454
+ const intersection = new THREE.Vector3()
455
+ if (_raycaster.ray.intersectPlane(plane, intersection)) {
456
+ lookAtTarget.x = intersection.x
457
+ lookAtTarget.y = intersection.y
458
+ lookAtTarget.z = intersection.z
459
+ if (vrm) {
460
+ saccades.instantUpdate(vrm, lookAtTarget)
461
+ }
462
+ }
463
+ }
464
+ // Listen on both window and document to handle transparent window cases
465
+ window.addEventListener('mousemove', onMouseMove)
466
+ document.addEventListener('mousemove', onMouseMove)
467
+
468
+ // ── Scroll zoom ──────────────────────────────────────────────────────────
469
+ const MIN_RADIUS = 0.8
470
+ const MAX_RADIUS = 5.0
471
+ const ZOOM_SPEED = 0.002
472
+
473
+ function onWheel(e: WheelEvent) {
474
+ e.preventDefault()
475
+ orbitRadius = THREE.MathUtils.clamp(
476
+ orbitRadius + e.deltaY * ZOOM_SPEED,
477
+ MIN_RADIUS,
478
+ MAX_RADIUS,
479
+ )
480
+ updateCameraOrbit()
481
+ }
482
+ canvas.addEventListener('wheel', onWheel, { passive: false })
483
+
484
+ // ── Drag controls ──────────────────────────────────────────────────────
485
+ // Left drag: move window (startDragging)
486
+ // Middle drag: dolly (zoom)
487
+ // Right drag: rotate around model
488
+ let dragMode: 'rotate' | 'dolly' | 'pan' | null = null
489
+ let prevX = 0
490
+ let prevY = 0
491
+ const ROTATE_SPEED = 0.005
492
+ const PAN_SPEED = 0.003
493
+ const DOLLY_SPEED = 0.01
494
+
495
+ // ── Touch interaction: detect body region from raycast hit ────────────
496
+ const touchRaycaster = new THREE.Raycaster()
497
+ const touchMouseVec = new THREE.Vector2()
498
+ let lastTapTime = 0
499
+ let lastTapRegion: TouchRegion | null = null
500
+ let lastTouchFireTime = 0
501
+ const DOUBLE_TAP_WINDOW = 500 // ms
502
+ const TOUCH_COOLDOWN = 5_000 // ms
503
+
504
+ // Bone-to-region mapping for proximity-based touch detection
505
+ const boneRegionMap: [string, TouchRegion][] = [
506
+ // Head
507
+ ['head', 'head'], ['neck', 'head'],
508
+ ['leftEye', 'head'], ['rightEye', 'head'], ['jaw', 'head'],
509
+ // Arms
510
+ ['leftShoulder', 'arm'], ['leftUpperArm', 'arm'], ['leftLowerArm', 'arm'], ['leftHand', 'arm'],
511
+ ['rightShoulder', 'arm'], ['rightUpperArm', 'arm'], ['rightLowerArm', 'arm'], ['rightHand', 'arm'],
512
+ // Legs
513
+ ['leftUpperLeg', 'leg'], ['leftLowerLeg', 'leg'], ['leftFoot', 'leg'], ['leftToes', 'leg'],
514
+ ['rightUpperLeg', 'leg'], ['rightLowerLeg', 'leg'], ['rightFoot', 'leg'], ['rightToes', 'leg'],
515
+ // Torso
516
+ ['chest', 'chest'], ['upperChest', 'chest'],
517
+ ['spine', 'belly'],
518
+ ['hips', 'buttocks'],
519
+ ]
520
+ const _bonePos = new THREE.Vector3()
521
+
522
+ function detectTouchRegion(e: PointerEvent): TouchRegion | null {
523
+ if (!vrm?.humanoid) return null
524
+
525
+ touchMouseVec.set(
526
+ (e.clientX / window.innerWidth) * 2 - 1,
527
+ -(e.clientY / window.innerHeight) * 2 + 1,
528
+ )
529
+ touchRaycaster.setFromCamera(touchMouseVec, camera)
530
+
531
+ const intersects = touchRaycaster.intersectObject(vrm.scene, true)
532
+ if (intersects.length === 0) return null
533
+
534
+ const hitPoint = intersects[0].point
535
+
536
+ // Find closest bone to hit point
537
+ let closestRegion: TouchRegion = 'belly'
538
+ let closestDist = Infinity
539
+
540
+ for (const [boneName, region] of boneRegionMap) {
541
+ const bone = vrm.humanoid.getNormalizedBoneNode(boneName as any)
542
+ if (!bone) continue
543
+ bone.getWorldPosition(_bonePos)
544
+ const dist = hitPoint.distanceToSquared(_bonePos)
545
+ if (dist < closestDist) {
546
+ closestDist = dist
547
+ closestRegion = region
548
+ }
549
+ }
550
+
551
+ return closestRegion
552
+ }
553
+
554
+ // ── Left-click: distinguish click (touch) vs drag (move window) ────
555
+ let leftDownPos: { x: number; y: number; time: number; region: TouchRegion | null } | null = null
556
+ const CLICK_MOVE_THRESHOLD = 5 // px
557
+ const CLICK_TIME_THRESHOLD = 300 // ms
558
+
559
+ function onPointerDown(e: PointerEvent) {
560
+ if (e.button === 0) {
561
+ const region = detectTouchRegion(e)
562
+ if (region) {
563
+ // Might be a touch — wait for pointerup to confirm it's not a drag
564
+ leftDownPos = { x: e.clientX, y: e.clientY, time: Date.now(), region }
565
+ return
566
+ }
567
+ // Not on model — no window drag available in web mode
568
+ return
569
+ } else if (e.button === 1) {
570
+ dragMode = 'dolly'
571
+ e.preventDefault()
572
+ } else if (e.button === 2) {
573
+ dragMode = 'rotate'
574
+ } else {
575
+ return
576
+ }
577
+ prevX = e.clientX
578
+ prevY = e.clientY
579
+ canvas!.setPointerCapture(e.pointerId)
580
+ }
581
+
582
+ function onPointerMove(e: PointerEvent) {
583
+ // If left button held on model and moved beyond threshold → it's a drag, not a touch
584
+ if (leftDownPos && e.buttons & 1) {
585
+ const dx = Math.abs(e.clientX - leftDownPos.x)
586
+ const dy = Math.abs(e.clientY - leftDownPos.y)
587
+ if (dx > CLICK_MOVE_THRESHOLD || dy > CLICK_MOVE_THRESHOLD) {
588
+ leftDownPos = null
589
+ return
590
+ }
591
+ }
592
+ if (!dragMode) return
593
+ const dx = e.clientX - prevX
594
+ const dy = e.clientY - prevY
595
+ prevX = e.clientX
596
+ prevY = e.clientY
597
+
598
+ if (dragMode === 'rotate') {
599
+ orbitTheta -= dx * ROTATE_SPEED
600
+ orbitPhi = THREE.MathUtils.clamp(
601
+ orbitPhi - dy * ROTATE_SPEED,
602
+ 0.1,
603
+ Math.PI - 0.1,
604
+ )
605
+ } else if (dragMode === 'dolly') {
606
+ orbitRadius = THREE.MathUtils.clamp(
607
+ orbitRadius + dy * DOLLY_SPEED,
608
+ MIN_RADIUS,
609
+ MAX_RADIUS,
610
+ )
611
+ } else if (dragMode === 'pan') {
612
+ pivot.x -= dx * PAN_SPEED
613
+ pivot.y += dy * PAN_SPEED
614
+ }
615
+ updateCameraOrbit()
616
+ }
617
+
618
+ function onPointerUp(e: PointerEvent) {
619
+ // Confirm touch: short press with no movement on model
620
+ if (leftDownPos && e.button === 0) {
621
+ const elapsed = Date.now() - leftDownPos.time
622
+ const dx = Math.abs(e.clientX - leftDownPos.x)
623
+ const dy = Math.abs(e.clientY - leftDownPos.y)
624
+ if (elapsed < CLICK_TIME_THRESHOLD && dx <= CLICK_MOVE_THRESHOLD && dy <= CLICK_MOVE_THRESHOLD) {
625
+ const now = Date.now()
626
+ const region = leftDownPos.region!
627
+ if (now - lastTapTime < DOUBLE_TAP_WINDOW && lastTapRegion === region && now - lastTouchFireTime > TOUCH_COOLDOWN) {
628
+ // Double-tap confirmed
629
+ lastTouchFireTime = now
630
+ spawnRipple(e.clientX, e.clientY, true)
631
+ onTouchRef.current?.(region)
632
+ lastTapTime = 0
633
+ lastTapRegion = null
634
+ } else {
635
+ // First tap — wait for second
636
+ spawnRipple(e.clientX, e.clientY, false)
637
+ lastTapTime = now
638
+ lastTapRegion = region
639
+ }
640
+ }
641
+ leftDownPos = null
642
+ return
643
+ }
644
+ if (dragMode) {
645
+ dragMode = null
646
+ canvas!.releasePointerCapture(e.pointerId)
647
+ }
648
+ }
649
+
650
+ function onContextMenu(e: Event) {
651
+ e.preventDefault()
652
+ }
653
+
654
+ canvas.addEventListener('pointerdown', onPointerDown)
655
+ canvas.addEventListener('pointermove', onPointerMove)
656
+ canvas.addEventListener('pointerup', onPointerUp)
657
+ canvas.addEventListener('contextmenu', onContextMenu)
658
+
659
+ // ── Resize ────────────────────────────────────────────────────────────────
660
+ function onResize() {
661
+ camera.aspect = window.innerWidth / window.innerHeight
662
+ camera.updateProjectionMatrix()
663
+ renderer.setSize(window.innerWidth, window.innerHeight)
664
+ }
665
+ window.addEventListener('resize', onResize)
666
+
667
+ // ── Hit-test for window pass-through ──────────────────────────────────────
668
+ // Offscreen render target: render scene, read 1 pixel alpha at cursor.
669
+ // Updated in the render loop — no extra render pass, just piggybacks.
670
+ const hitTarget = new THREE.WebGLRenderTarget(1, 1, { depthBuffer: false })
671
+ let pendingHitTest: { x: number; y: number; resolve: (hit: boolean) => void } | null = null
672
+ const hitPixel = new Uint8Array(4)
673
+
674
+ // Async hit-test: queues a request, resolved after next frame render
675
+ ;(window as any).__clawHitTest = (clientX: number, clientY: number): Promise<boolean> => {
676
+ return new Promise((resolve) => {
677
+ pendingHitTest = { x: clientX, y: clientY, resolve }
678
+ })
679
+ }
680
+
681
+ // ── Animation loop ────────────────────────────────────────────────────────
682
+ const clock = new THREE.Clock()
683
+ let animFrameId: number
684
+
685
+ function animate() {
686
+ animFrameId = requestAnimationFrame(animate)
687
+ const delta = clock.getDelta()
688
+
689
+ if (vrm) {
690
+ // 1. Animation mixer
691
+ motion?.update(delta)
692
+
693
+ // 1.5. Relaxed hand pose — skip during dance (VMD has own hand anim)
694
+ if (handPose && !motion?.isDancing) applyRelaxedHandPose(handPose, clock.elapsedTime)
695
+
696
+ // 2. Humanoid update
697
+ vrm.humanoid?.update()
698
+
699
+ // 3. Camera tracking mode: look at camera position
700
+ if (trackingModeRef.current === 'camera') {
701
+ lookAtTarget.x = camera.position.x
702
+ lookAtTarget.y = camera.position.y
703
+ lookAtTarget.z = camera.position.z
704
+ saccades.instantUpdate(vrm, lookAtTarget)
705
+ }
706
+
707
+ // 4. LookAt update
708
+ vrm.lookAt?.update(delta)
709
+
710
+ // 5. Eye saccades (airi style)
711
+ saccades.update(vrm, lookAtTarget, delta)
712
+
713
+ // 5. Blinking
714
+ updateBlink(vrm, delta, blinkState)
715
+
716
+ // 6. Emote transitions
717
+ emote?.update(delta)
718
+
719
+ // 7. Lip sync
720
+ lipSyncRef.current.update(vrm, delta)
721
+
722
+ // 8. Expression manager (apply blink etc.)
723
+ vrm.expressionManager?.update()
724
+
725
+ // 8. Spring bone physics
726
+ vrm.springBoneManager?.update(delta)
727
+ }
728
+
729
+ renderer.render(scene, camera)
730
+
731
+ // Process pending hit-test after render
732
+ if (pendingHitTest && canvas) {
733
+ const { x, y, resolve } = pendingHitTest
734
+ pendingHitTest = null
735
+
736
+ if (!vrm) {
737
+ resolve(true) // Model not loaded — don't pass through
738
+ } else {
739
+
740
+ const dpr = renderer.getPixelRatio()
741
+ const bufW = canvas.clientWidth * dpr
742
+ const bufH = canvas.clientHeight * dpr
743
+
744
+ if (hitTarget.width !== bufW || hitTarget.height !== bufH) {
745
+ hitTarget.setSize(bufW, bufH)
746
+ }
747
+
748
+ // Render to offscreen target
749
+ renderer.setRenderTarget(hitTarget)
750
+ renderer.clear()
751
+ renderer.render(scene, camera)
752
+ // Read 1 pixel at cursor position
753
+ const px = Math.floor(x * dpr)
754
+ const py = Math.floor(bufH - y * dpr) // GL Y-flip
755
+ renderer.readRenderTargetPixels(hitTarget, px, py, 1, 1, hitPixel)
756
+ renderer.setRenderTarget(null)
757
+
758
+ resolve(hitPixel[3] > 10)
759
+ } // end else (vrm exists)
760
+ }
761
+ }
762
+
763
+ animate()
764
+
765
+ // ── Cleanup ───────────────────────────────────────────────────────────────
766
+ return () => {
767
+ cancelAnimationFrame(animFrameId)
768
+ window.removeEventListener('mousemove', onMouseMove)
769
+ document.removeEventListener('mousemove', onMouseMove)
770
+ canvas.removeEventListener('wheel', onWheel)
771
+ canvas.removeEventListener('pointerdown', onPointerDown)
772
+ canvas.removeEventListener('pointermove', onPointerMove)
773
+ canvas.removeEventListener('pointerup', onPointerUp)
774
+ canvas.removeEventListener('contextmenu', onContextMenu)
775
+ window.removeEventListener('resize', onResize)
776
+ emote?.dispose()
777
+ emoteRef.current = null
778
+ motion?.dispose()
779
+ motionRef.current = null
780
+ hitTarget.dispose()
781
+ delete (window as any).__clawHitTest
782
+ renderer.dispose()
783
+ }
784
+ }, [modelPath, idleAnimationPath])
785
+
786
+ return (
787
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>
788
+ <canvas
789
+ ref={canvasRef}
790
+ style={{
791
+ display: 'block',
792
+ width: '100%',
793
+ height: '100%',
794
+ background: 'transparent',
795
+ cursor: 'grab',
796
+ }}
797
+ />
798
+ {ripples.map(r => (
799
+ <span
800
+ key={r.id}
801
+ className={r.confirmed ? 'touch-ripple confirmed' : 'touch-ripple'}
802
+ style={{ left: r.x, top: r.y }}
803
+ />
804
+ ))}
805
+ </div>
806
+ )
807
+ })
808
+
809
+ // ── Eye saccade interval (from airi) ─────────────────────────────────────────
810
+ const EYE_SACCADE_INT_STEP = 400
811
+ const EYE_SACCADE_INT_P: number[][] = [
812
+ [0.075, 800], [0.110, 0], [0.125, 0], [0.140, 0], [0.125, 0],
813
+ [0.050, 0], [0.040, 0], [0.030, 0], [0.020, 0], [1.000, 0],
814
+ ]
815
+ for (let i = 1; i < EYE_SACCADE_INT_P.length; i++) {
816
+ EYE_SACCADE_INT_P[i][0] += EYE_SACCADE_INT_P[i - 1][0]
817
+ EYE_SACCADE_INT_P[i][1] = EYE_SACCADE_INT_P[i - 1][1] + EYE_SACCADE_INT_STEP
818
+ }
819
+
820
+ function randomSaccadeInterval(): number {
821
+ const r = Math.random()
822
+ for (let i = 0; i < EYE_SACCADE_INT_P.length; i++) {
823
+ if (r <= EYE_SACCADE_INT_P[i][0]) {
824
+ return EYE_SACCADE_INT_P[i][1] + Math.random() * EYE_SACCADE_INT_STEP
825
+ }
826
+ }
827
+ return EYE_SACCADE_INT_P[EYE_SACCADE_INT_P.length - 1][1] + Math.random() * EYE_SACCADE_INT_STEP
828
+ }
829
+
830
+ // ── Eye saccade controller (from airi) ───────────────────────────────────────
831
+ class EyeSaccadeController {
832
+ private nextSaccadeAfter = -1
833
+ private timeSinceLastSaccade = 0
834
+ private fixationTarget = new THREE.Vector3()
835
+
836
+ /** Called when lookAt target changes (e.g. mouse moved) */
837
+ instantUpdate(vrm: VRM, target: { x: number; y: number; z: number }) {
838
+ this.fixationTarget.set(target.x, target.y, target.z)
839
+ if (!vrm.lookAt) return
840
+ if (!vrm.lookAt.target) {
841
+ vrm.lookAt.target = new THREE.Object3D()
842
+ }
843
+ vrm.lookAt.target.position.copy(this.fixationTarget)
844
+ vrm.lookAt.update(0.016)
845
+ }
846
+
847
+ /** Called every frame */
848
+ update(vrm: VRM, lookAtTarget: { x: number; y: number; z: number }, delta: number) {
849
+ if (!vrm.expressionManager || !vrm.lookAt) return
850
+
851
+ if (this.timeSinceLastSaccade >= this.nextSaccadeAfter) {
852
+ // Add random offset to the current lookAt target
853
+ this.fixationTarget.set(
854
+ lookAtTarget.x + THREE.MathUtils.randFloat(-0.25, 0.25),
855
+ lookAtTarget.y + THREE.MathUtils.randFloat(-0.25, 0.25),
856
+ lookAtTarget.z,
857
+ )
858
+ this.timeSinceLastSaccade = 0
859
+ this.nextSaccadeAfter = randomSaccadeInterval() / 1000
860
+ }
861
+
862
+ if (!vrm.lookAt.target) {
863
+ vrm.lookAt.target = new THREE.Object3D()
864
+ }
865
+ vrm.lookAt.target.position.lerp(this.fixationTarget, 1)
866
+ vrm.lookAt.update(delta)
867
+
868
+ this.timeSinceLastSaccade += delta
869
+ }
870
+ }
friend/frontend/src/emote.ts ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { VRM } from '@pixiv/three-vrm'
2
+
3
+ interface EmotionExpr {
4
+ name: string
5
+ value: number
6
+ }
7
+
8
+ interface EmotionState {
9
+ expression: EmotionExpr[]
10
+ blendDuration: number
11
+ }
12
+
13
+ const emotionStates = new Map<string, EmotionState>([
14
+ ['happy', {
15
+ expression: [
16
+ { name: 'happy', value: 0.2 },
17
+ { name: 'aa', value: 0.8 },
18
+ ],
19
+ blendDuration: 0.4,
20
+ }],
21
+ ['sad', {
22
+ expression: [
23
+ { name: 'sad', value: 0.7 },
24
+ { name: 'oh', value: 0.15 },
25
+ ],
26
+ blendDuration: 0.4,
27
+ }],
28
+ ['angry', {
29
+ expression: [
30
+ { name: 'angry', value: 0.7 },
31
+ { name: 'ee', value: 0.3 },
32
+ ],
33
+ blendDuration: 0.3,
34
+ }],
35
+ ['surprised', {
36
+ expression: [
37
+ { name: 'surprised', value: 0.8 },
38
+ { name: 'oh', value: 0.4 },
39
+ ],
40
+ blendDuration: 0.15,
41
+ }],
42
+ ['think', {
43
+ expression: [
44
+ { name: 'think', value: 0.7 },
45
+ ],
46
+ blendDuration: 0.5,
47
+ }],
48
+ ['awkward', {
49
+ expression: [
50
+ { name: 'sad', value: 0.3 },
51
+ { name: 'ee', value: 0.2 },
52
+ ],
53
+ blendDuration: 0.5,
54
+ }],
55
+ ['question', {
56
+ expression: [
57
+ { name: 'surprised', value: 0.4 },
58
+ { name: 'think', value: 0.3 },
59
+ ],
60
+ blendDuration: 0.4,
61
+ }],
62
+ ['curious', {
63
+ expression: [
64
+ { name: 'think', value: 0.5 },
65
+ { name: 'surprised', value: 0.2 },
66
+ ],
67
+ blendDuration: 0.4,
68
+ }],
69
+ ['neutral', {
70
+ expression: [
71
+ { name: 'neutral', value: 1.0 },
72
+ ],
73
+ blendDuration: 0.6,
74
+ }],
75
+ ['love', {
76
+ expression: [
77
+ { name: 'happy', value: 0.2 },
78
+ { name: 'relaxed', value: 0.4 },
79
+ ],
80
+ blendDuration: 0.4,
81
+ }],
82
+ ['flirty', {
83
+ expression: [
84
+ { name: 'happy', value: 0.2 },
85
+ { name: 'relaxed', value: 0.3 },
86
+ { name: 'aa', value: 0.15 },
87
+ ],
88
+ blendDuration: 0.4,
89
+ }],
90
+ ['greeting', {
91
+ expression: [
92
+ { name: 'happy', value: 0.2 },
93
+ { name: 'aa', value: 0.3 },
94
+ ],
95
+ blendDuration: 0.3,
96
+ }],
97
+ ['relaxed', {
98
+ expression: [
99
+ { name: 'relaxed', value: 0.8 },
100
+ ],
101
+ blendDuration: 0.5,
102
+ }],
103
+ ])
104
+
105
+ export class EmoteController {
106
+ private vrm: VRM
107
+ private currentEmotion: string | null = null
108
+ private isTransitioning = false
109
+ private transitionProgress = 0
110
+ private currentValues = new Map<string, number>()
111
+ private targetValues = new Map<string, number>()
112
+ private resetTimer: ReturnType<typeof setTimeout> | null = null
113
+
114
+ constructor(vrm: VRM) {
115
+ this.vrm = vrm
116
+ }
117
+
118
+ setEmotion(emotionName: string, intensity = 1) {
119
+ if (this.resetTimer) {
120
+ clearTimeout(this.resetTimer)
121
+ this.resetTimer = null
122
+ }
123
+
124
+ const state = emotionStates.get(emotionName)
125
+ if (!state) {
126
+ console.warn(`Emotion "${emotionName}" not found`)
127
+ return
128
+ }
129
+
130
+ this.currentEmotion = emotionName
131
+ this.isTransitioning = true
132
+ this.transitionProgress = 0
133
+ this.currentValues.clear()
134
+ this.targetValues.clear()
135
+
136
+ const clampedIntensity = Math.min(1, Math.max(0, intensity))
137
+
138
+ // Capture current expression values as start point
139
+ if (this.vrm.expressionManager) {
140
+ const names = Object.keys(this.vrm.expressionManager.expressionMap)
141
+ for (const name of names) {
142
+ this.currentValues.set(name, this.vrm.expressionManager.getValue(name) || 0)
143
+ this.targetValues.set(name, 0)
144
+ }
145
+ }
146
+
147
+ // Set targets for this emotion
148
+ for (const expr of state.expression) {
149
+ this.targetValues.set(expr.name, expr.value * clampedIntensity)
150
+ }
151
+ }
152
+
153
+ setEmotionWithReset(emotionName: string, durationMs: number, intensity = 1) {
154
+ this.setEmotion(emotionName, intensity)
155
+ this.resetTimer = setTimeout(() => {
156
+ this.setEmotion('neutral')
157
+ this.resetTimer = null
158
+ }, durationMs)
159
+ }
160
+
161
+ update(deltaTime: number) {
162
+ if (!this.isTransitioning || !this.currentEmotion) return
163
+
164
+ const state = emotionStates.get(this.currentEmotion)!
165
+ this.transitionProgress += deltaTime / state.blendDuration
166
+
167
+ if (this.transitionProgress >= 1) {
168
+ this.transitionProgress = 1
169
+ this.isTransitioning = false
170
+ }
171
+
172
+ const t = this.transitionProgress
173
+ const ease = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2
174
+
175
+ for (const [name, target] of this.targetValues) {
176
+ const start = this.currentValues.get(name) || 0
177
+ const value = start + (target - start) * ease
178
+ this.vrm.expressionManager?.setValue(name, value)
179
+ }
180
+ }
181
+
182
+ /** Reset all expressions to zero (idle state) without transition. */
183
+ resetAll() {
184
+ if (this.resetTimer) {
185
+ clearTimeout(this.resetTimer)
186
+ this.resetTimer = null
187
+ }
188
+ this.isTransitioning = false
189
+ this.currentEmotion = null
190
+ if (this.vrm.expressionManager) {
191
+ const names = Object.keys(this.vrm.expressionManager.expressionMap)
192
+ for (const name of names) {
193
+ this.vrm.expressionManager.setValue(name, 0)
194
+ }
195
+ }
196
+ this.currentValues.clear()
197
+ this.targetValues.clear()
198
+ }
199
+
200
+ dispose() {
201
+ if (this.resetTimer) {
202
+ clearTimeout(this.resetTimer)
203
+ this.resetTimer = null
204
+ }
205
+ }
206
+ }
friend/frontend/src/hooks/usePassThrough.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Web fallback for Tauri window pass-through.
3
+ * In the web version, cursor pass-through is not available.
4
+ * This is a no-op.
5
+ */
6
+ import { useEffect } from 'react'
7
+
8
+ export function usePassThrough(_enabled: boolean) {
9
+ useEffect(() => {
10
+ // No-op in web mode — pass-through is a Tauri-only feature
11
+ }, [_enabled])
12
+ }
friend/frontend/src/lip-sync.ts ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { VRM } from '@pixiv/three-vrm'
2
+ import type { Profile } from 'wlipsync'
3
+ import { createWLipSyncNode } from 'wlipsync'
4
+ import profile from './assets/lip-sync-profile.json'
5
+
6
+ type LipKey = 'A' | 'E' | 'I' | 'O' | 'U'
7
+ const RAW_KEYS = ['A', 'E', 'I', 'O', 'U', 'S'] as const
8
+ const LIP_KEYS: LipKey[] = ['A', 'E', 'I', 'O', 'U']
9
+ const BLENDSHAPE_MAP: Record<LipKey, string> = {
10
+ A: 'aa',
11
+ E: 'ee',
12
+ I: 'ih',
13
+ O: 'oh',
14
+ U: 'ou',
15
+ }
16
+ const RAW_TO_LIP: Record<typeof RAW_KEYS[number], LipKey> = {
17
+ A: 'A', E: 'E', I: 'I', O: 'O', U: 'U', S: 'I',
18
+ }
19
+
20
+ const ATTACK = 50
21
+ const RELEASE = 30
22
+ const CAP = 0.7
23
+ const SILENCE_VOL = 0.04
24
+ const SILENCE_GAIN = 0.05
25
+ const IDLE_MS = 160
26
+
27
+ let singleton: LipSync | null = null
28
+
29
+ export class LipSync {
30
+ private audioContext: AudioContext | null = null
31
+ private lipSyncNode: any = null
32
+ private gainNode: GainNode | null = null
33
+ private smoothState: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
34
+ private lastActiveAt = 0
35
+ private currentSource: AudioBufferSourceNode | null = null
36
+ private ready = false
37
+ private initPromise: Promise<void> | null = null
38
+
39
+ static getInstance(): LipSync {
40
+ if (!singleton) singleton = new LipSync()
41
+ return singleton
42
+ }
43
+
44
+ private constructor() {}
45
+
46
+ private async ensureReady() {
47
+ if (this.ready) return
48
+ if (this.initPromise) return this.initPromise
49
+ this.initPromise = (async () => {
50
+ this.audioContext = new AudioContext()
51
+ this.lipSyncNode = await createWLipSyncNode(this.audioContext, profile as Profile)
52
+ // lipSyncNode is analysis-only, no need to connect to destination
53
+ this.gainNode = this.audioContext.createGain()
54
+ this.gainNode.connect(this.audioContext.destination)
55
+ this.ready = true
56
+ })()
57
+ return this.initPromise
58
+ }
59
+
60
+ /**
61
+ * Fetch audio from URL, decode it, and play through Web Audio API.
62
+ * Returns duration (ms) once playback starts. Sound goes to both
63
+ * lipSyncNode (for mouth analysis) and destination (for speakers).
64
+ */
65
+ async playAudio(url: string): Promise<number> {
66
+ await this.ensureReady()
67
+ if (!this.lipSyncNode || !this.audioContext) return 0
68
+
69
+ // Stop previous source
70
+ if (this.currentSource) {
71
+ try { this.currentSource.stop() } catch {}
72
+ try { this.currentSource.disconnect() } catch {}
73
+ this.currentSource = null
74
+ }
75
+
76
+ if (this.audioContext.state === 'suspended') {
77
+ await this.audioContext.resume()
78
+ }
79
+
80
+ let response: Response
81
+ try {
82
+ response = await fetch(url)
83
+ } catch (err) {
84
+ console.error('[LipSync] fetch failed:', url, err)
85
+ return 0
86
+ }
87
+ if (!response.ok) {
88
+ console.error('[LipSync] fetch error:', url, response.status)
89
+ return 0
90
+ }
91
+ const arrayBuffer = await response.arrayBuffer()
92
+ if (arrayBuffer.byteLength === 0) {
93
+ console.error('[LipSync] empty response:', url)
94
+ return 0
95
+ }
96
+ const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
97
+
98
+ const source = this.audioContext.createBufferSource()
99
+ source.buffer = audioBuffer
100
+ source.connect(this.lipSyncNode)
101
+ source.connect(this.gainNode!)
102
+ this.currentSource = source
103
+ source.start()
104
+
105
+ return audioBuffer.duration * 1000
106
+ }
107
+
108
+ setVolume(value: number) {
109
+ if (this.gainNode) {
110
+ this.gainNode.gain.value = value
111
+ }
112
+ }
113
+
114
+ update(vrm: VRM, delta: number) {
115
+ const node = this.lipSyncNode
116
+ if (!vrm.expressionManager || !node || !this.ready) return
117
+
118
+ const vol = node.volume ?? 0
119
+ const amp = Math.min(vol * 0.9, 1) ** 0.7
120
+
121
+ const projected: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
122
+ for (const raw of RAW_KEYS) {
123
+ const lip = RAW_TO_LIP[raw]
124
+ const rawVal = node.weights[raw] ?? 0
125
+ projected[lip] = Math.max(projected[lip], rawVal * amp)
126
+ }
127
+
128
+ let winner: LipKey = 'I'
129
+ let runner: LipKey = 'E'
130
+ let winnerVal = -Infinity
131
+ let runnerVal = -Infinity
132
+ for (const key of LIP_KEYS) {
133
+ const val = projected[key]
134
+ if (val > winnerVal) {
135
+ runnerVal = winnerVal
136
+ runner = winner
137
+ winnerVal = val
138
+ winner = key
139
+ } else if (val > runnerVal) {
140
+ runnerVal = val
141
+ runner = key
142
+ }
143
+ }
144
+
145
+ const now = performance.now()
146
+ let silent = amp < SILENCE_VOL || winnerVal < SILENCE_GAIN
147
+ if (!silent) this.lastActiveAt = now
148
+ if (now - this.lastActiveAt > IDLE_MS) silent = true
149
+
150
+ const target: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
151
+ if (!silent) {
152
+ target[winner] = Math.min(CAP, winnerVal)
153
+ target[runner] = Math.min(CAP * 0.5, runnerVal * 0.6)
154
+ }
155
+
156
+ for (const key of LIP_KEYS) {
157
+ const from = this.smoothState[key]
158
+ const to = target[key]
159
+ const rate = 1 - Math.exp(-(to > from ? ATTACK : RELEASE) * delta)
160
+ this.smoothState[key] = from + (to - from) * rate
161
+
162
+ // When fully silent and decayed, skip writing so emote can control mouth morphs
163
+ if (silent && this.smoothState[key] <= 0.01) continue
164
+
165
+ const weight = (this.smoothState[key] <= 0.01 ? 0 : this.smoothState[key]) * 0.7
166
+ vrm.expressionManager.setValue(BLENDSHAPE_MAP[key], weight)
167
+ }
168
+ }
169
+
170
+ /** Stop current audio playback immediately. */
171
+ stopAudio() {
172
+ if (this.currentSource) {
173
+ try { this.currentSource.stop() } catch {}
174
+ try { this.currentSource.disconnect() } catch {}
175
+ this.currentSource = null
176
+ }
177
+ }
178
+
179
+ dispose() {
180
+ this.stopAudio()
181
+ if (this.lipSyncNode) {
182
+ try { this.lipSyncNode.disconnect() } catch {}
183
+ }
184
+ this.audioContext?.close()
185
+ singleton = null
186
+ }
187
+ }
friend/frontend/src/main.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+
5
+ ReactDOM.createRoot(document.getElementById('root')!).render(
6
+ <React.StrictMode>
7
+ <App />
8
+ </React.StrictMode>,
9
+ )
friend/frontend/src/mixamo-loader.ts ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Mixamo FBX animation loader for VRM models.
3
+ * Ported from lobe-vidol's loadMixamoAnimation.
4
+ */
5
+
6
+ import type { VRM } from '@pixiv/three-vrm'
7
+ import type { VRMHumanBoneName } from '@pixiv/three-vrm-core'
8
+ import * as THREE from 'three'
9
+ import { FBXLoader } from 'three/addons/loaders/FBXLoader.js'
10
+
11
+ const mixamoVRMRigMap: Record<string, VRMHumanBoneName> = {
12
+ mixamorigHips: 'hips',
13
+ mixamorigSpine: 'spine',
14
+ mixamorigSpine1: 'chest',
15
+ mixamorigSpine2: 'upperChest',
16
+ mixamorigNeck: 'neck',
17
+ mixamorigHead: 'head',
18
+ mixamorigLeftShoulder: 'leftShoulder',
19
+ mixamorigLeftArm: 'leftUpperArm',
20
+ mixamorigLeftForeArm: 'leftLowerArm',
21
+ mixamorigLeftHand: 'leftHand',
22
+ mixamorigLeftHandThumb1: 'leftThumbMetacarpal',
23
+ mixamorigLeftHandThumb2: 'leftThumbProximal',
24
+ mixamorigLeftHandThumb3: 'leftThumbDistal',
25
+ mixamorigLeftHandIndex1: 'leftIndexProximal',
26
+ mixamorigLeftHandIndex2: 'leftIndexIntermediate',
27
+ mixamorigLeftHandIndex3: 'leftIndexDistal',
28
+ mixamorigLeftHandMiddle1: 'leftMiddleProximal',
29
+ mixamorigLeftHandMiddle2: 'leftMiddleIntermediate',
30
+ mixamorigLeftHandMiddle3: 'leftMiddleDistal',
31
+ mixamorigLeftHandRing1: 'leftRingProximal',
32
+ mixamorigLeftHandRing2: 'leftRingIntermediate',
33
+ mixamorigLeftHandRing3: 'leftRingDistal',
34
+ mixamorigLeftHandPinky1: 'leftLittleProximal',
35
+ mixamorigLeftHandPinky2: 'leftLittleIntermediate',
36
+ mixamorigLeftHandPinky3: 'leftLittleDistal',
37
+ mixamorigRightShoulder: 'rightShoulder',
38
+ mixamorigRightArm: 'rightUpperArm',
39
+ mixamorigRightForeArm: 'rightLowerArm',
40
+ mixamorigRightHand: 'rightHand',
41
+ mixamorigRightHandPinky1: 'rightLittleProximal',
42
+ mixamorigRightHandPinky2: 'rightLittleIntermediate',
43
+ mixamorigRightHandPinky3: 'rightLittleDistal',
44
+ mixamorigRightHandRing1: 'rightRingProximal',
45
+ mixamorigRightHandRing2: 'rightRingIntermediate',
46
+ mixamorigRightHandRing3: 'rightRingDistal',
47
+ mixamorigRightHandMiddle1: 'rightMiddleProximal',
48
+ mixamorigRightHandMiddle2: 'rightMiddleIntermediate',
49
+ mixamorigRightHandMiddle3: 'rightMiddleDistal',
50
+ mixamorigRightHandIndex1: 'rightIndexProximal',
51
+ mixamorigRightHandIndex2: 'rightIndexIntermediate',
52
+ mixamorigRightHandIndex3: 'rightIndexDistal',
53
+ mixamorigRightHandThumb1: 'rightThumbMetacarpal',
54
+ mixamorigRightHandThumb2: 'rightThumbProximal',
55
+ mixamorigRightHandThumb3: 'rightThumbDistal',
56
+ mixamorigLeftUpLeg: 'leftUpperLeg',
57
+ mixamorigLeftLeg: 'leftLowerLeg',
58
+ mixamorigLeftFoot: 'leftFoot',
59
+ mixamorigLeftToeBase: 'leftToes',
60
+ mixamorigRightUpLeg: 'rightUpperLeg',
61
+ mixamorigRightLeg: 'rightLowerLeg',
62
+ mixamorigRightFoot: 'rightFoot',
63
+ mixamorigRightToeBase: 'rightToes',
64
+ }
65
+
66
+ export async function loadMixamoAnimation(url: string, vrm: VRM): Promise<THREE.AnimationClip> {
67
+ const loader = new FBXLoader()
68
+ let asset: THREE.Group
69
+ try {
70
+ asset = await loader.loadAsync(url) as THREE.Group
71
+ } catch (err) {
72
+ console.error('[MixamoLoader] FBXLoader.loadAsync failed:', url, err)
73
+ throw err
74
+ }
75
+ // Try 'mixamo.com' first, fall back to first available animation
76
+ const clip = THREE.AnimationClip.findByName(asset.animations, 'mixamo.com')
77
+ ?? asset.animations[0]
78
+ if (!clip) throw new Error('No animation clip found in FBX')
79
+
80
+ const tracks: THREE.KeyframeTrack[] = []
81
+ const restRotationInverse = new THREE.Quaternion()
82
+ const parentRestWorldRotation = new THREE.Quaternion()
83
+ const _quatA = new THREE.Quaternion()
84
+ const _vec3 = new THREE.Vector3()
85
+
86
+ // Scale based on hips height ratio
87
+ const hipsObj = asset.getObjectByName('mixamorigHips')
88
+ if (!hipsObj) throw new Error('No mixamorigHips bone found in FBX')
89
+ const motionHipsHeight = hipsObj.position.y
90
+ const vrmHipsY = vrm.humanoid?.getNormalizedBoneNode('hips')?.getWorldPosition(_vec3).y || 0
91
+ const vrmRootY = vrm.scene.getWorldPosition(_vec3).y
92
+ const vrmHipsHeight = Math.abs(vrmHipsY - vrmRootY)
93
+ const hipsPositionScale = vrmHipsHeight / motionHipsHeight
94
+
95
+ clip.tracks.forEach((track) => {
96
+ const trackSplitted = track.name.split('.')
97
+ const mixamoRigName = trackSplitted[0]
98
+ const vrmBoneName = mixamoVRMRigMap[mixamoRigName]
99
+ const vrmNodeName = vrm.humanoid?.getNormalizedBoneNode(vrmBoneName)?.name
100
+ const mixamoRigNode = asset.getObjectByName(mixamoRigName)
101
+
102
+ if (vrmNodeName != null) {
103
+ const propertyName = trackSplitted[1]
104
+
105
+ if (mixamoRigNode) {
106
+ mixamoRigNode.getWorldQuaternion(restRotationInverse).invert()
107
+ if (mixamoRigNode.parent)
108
+ mixamoRigNode.parent.getWorldQuaternion(parentRestWorldRotation)
109
+ }
110
+
111
+ if (track instanceof THREE.QuaternionKeyframeTrack) {
112
+ for (let i = 0; i < track.values.length; i += 4) {
113
+ const flatQuaternion = track.values.slice(i, i + 4)
114
+ _quatA.fromArray(flatQuaternion)
115
+ _quatA.premultiply(parentRestWorldRotation).multiply(restRotationInverse)
116
+ _quatA.toArray(flatQuaternion)
117
+ flatQuaternion.forEach((v, index) => {
118
+ track.values[index + i] = v
119
+ })
120
+ }
121
+
122
+ tracks.push(
123
+ new THREE.QuaternionKeyframeTrack(
124
+ `${vrmNodeName}.${propertyName}`,
125
+ Array.from(track.times),
126
+ Array.from(track.values).map((v, i) => (vrm.meta?.metaVersion === '0' && i % 2 === 0 ? -v : v)),
127
+ ),
128
+ )
129
+ } else if (track instanceof THREE.VectorKeyframeTrack) {
130
+ const value = Array.from(track.values).map(
131
+ (v, i) => (vrm.meta?.metaVersion === '0' && i % 3 !== 1 ? -v : v) * hipsPositionScale,
132
+ )
133
+ tracks.push(
134
+ new THREE.VectorKeyframeTrack(`${vrmNodeName}.${propertyName}`, Array.from(track.times), value),
135
+ )
136
+ }
137
+ }
138
+ })
139
+
140
+ return new THREE.AnimationClip('mixamoAnimation', clip.duration, tracks)
141
+ }
friend/frontend/src/mmd-parser.d.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ declare module 'mmd-parser' {
2
+ export const CharsetEncoder: any
3
+
4
+ export class Parser {
5
+ parseVmd(buffer: ArrayBufferLike, leftToRight?: boolean): VmdFile
6
+ }
7
+
8
+ export interface VmdFile {
9
+ metadata: {
10
+ magic: string
11
+ name: string
12
+ motionCount: number
13
+ morphCount: number
14
+ cameraCount: number
15
+ coordinateSystem: string
16
+ }
17
+ motions: {
18
+ boneName: string
19
+ frameNum: number
20
+ position: number[]
21
+ rotation: number[]
22
+ interpolation: number[]
23
+ }[]
24
+ morphs: {
25
+ morphName: string
26
+ frameNum: number
27
+ weight: number
28
+ }[]
29
+ cameras: {
30
+ frameNum: number
31
+ distance: number
32
+ position: number[]
33
+ rotation: number[]
34
+ interpolation: number[]
35
+ fov: number
36
+ perspective: number
37
+ }[]
38
+ }
39
+ }
friend/frontend/src/motion-controller.ts ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * MotionController — unified animation system supporting VRMA, VMD, and FBX.
3
+ *
4
+ * Uses a single persistent AnimationMixer with crossFade transitions to avoid
5
+ * T-pose flickering between animations.
6
+ */
7
+
8
+ import * as THREE from 'three'
9
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
10
+ import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation'
11
+ import type { VRMAnimation } from '@pixiv/three-vrm-animation'
12
+ import type { VRM } from '@pixiv/three-vrm'
13
+ import { parseVMDAnimation, bindVMDToVRM } from './vmd-loader'
14
+ import type { VMDAnimationData } from './vmd-loader'
15
+ import { loadMixamoAnimation } from './mixamo-loader'
16
+ import { VRMIKHandler } from './vrm-ik-handler'
17
+
18
+ // ── Motion file types ───────────────────────────────────────────────────────
19
+
20
+ export type MotionFileType = 'vrma' | 'vmd' | 'fbx'
21
+
22
+ // ── Motion presets ──────────────────────────────────────────────────────────
23
+
24
+ export interface MotionPreset {
25
+ label: string
26
+ type: MotionFileType
27
+ url: string
28
+ }
29
+
30
+ // Actions: short one-shot gestures triggered by emotions / interactions
31
+ export const actionPresets: Record<string, MotionPreset> = {
32
+ // Local VRMA actions (bundled)
33
+ akimbo: { label: '叉腰', type: 'vrma', url: '/friend/akimbo.vrma' },
34
+ playFingers: { label: '搓手', type: 'vrma', url: '/friend/playFingers.vrma' },
35
+ scratchHead: { label: '挠头', type: 'vrma', url: '/friend/scratchHead.vrma' },
36
+ stretch: { label: '伸展', type: 'vrma', url: '/friend/stretch.vrma' },
37
+
38
+ // Local FBX actions (bundled)
39
+ happy: { label: '开心', type: 'fbx', url: '/friend/happy.fbx' },
40
+ angry: { label: '生气', type: 'fbx', url: '/friend/angry.fbx' },
41
+ greeting: { label: '招呼', type: 'fbx', url: '/friend/greeting.fbx' },
42
+ excited: { label: '兴奋', type: 'fbx', url: '/friend/excited.fbx' },
43
+ shy: { label: '害羞', type: 'fbx', url: '/friend/shy.fbx' },
44
+ point: { label: '指点', type: 'fbx', url: '/friend/point.fbx' },
45
+ salute: { label: '敬礼', type: 'fbx', url: '/friend/salute.fbx' },
46
+ angryPump: { label: '暴怒', type: 'fbx', url: '/friend/angryPump.fbx' },
47
+ }
48
+
49
+ // Dances: looping full-body animations with optional BGM
50
+ export interface DancePreset extends MotionPreset {
51
+ bgm?: string
52
+ }
53
+
54
+ export const dancePresets: Record<string, DancePreset> = {
55
+ jile: { label: '极乐净土', type: 'vmd', url: '/friend/jile.vmd', bgm: '/friend/jile.mp3' },
56
+ love: { label: '恋爱循环', type: 'vmd', url: '/friend/love.vmd', bgm: '/friend/love.mp3' },
57
+ }
58
+
59
+ // ── Utility: re-anchor root position ────────────────────────────────────────
60
+
61
+ function reAnchorRootPositionTrack(clip: THREE.AnimationClip, vrm: VRM) {
62
+ const hipNode = vrm.humanoid?.getNormalizedBoneNode('hips')
63
+ if (!hipNode) return
64
+
65
+ hipNode.updateMatrixWorld(true)
66
+ const defaultHipPos = new THREE.Vector3()
67
+ hipNode.getWorldPosition(defaultHipPos)
68
+
69
+ const hipsTrack = clip.tracks.find(
70
+ (t) =>
71
+ t instanceof THREE.VectorKeyframeTrack &&
72
+ t.name === `${hipNode.name}.position`,
73
+ )
74
+ if (!(hipsTrack instanceof THREE.VectorKeyframeTrack)) return
75
+
76
+ const animeHipPos = new THREE.Vector3(
77
+ hipsTrack.values[0],
78
+ hipsTrack.values[1],
79
+ hipsTrack.values[2],
80
+ )
81
+ const delta = new THREE.Vector3().subVectors(animeHipPos, defaultHipPos)
82
+
83
+ clip.tracks.forEach((track) => {
84
+ if (
85
+ track.name.endsWith('.position') &&
86
+ track instanceof THREE.VectorKeyframeTrack
87
+ ) {
88
+ for (let i = 0; i < track.values.length; i += 3) {
89
+ track.values[i] -= delta.x
90
+ track.values[i + 1] -= delta.y
91
+ track.values[i + 2] -= delta.z
92
+ }
93
+ }
94
+ })
95
+ }
96
+
97
+ // ── MotionController ────────────────────────────────────────────────────────
98
+
99
+ export class MotionController {
100
+ private vrm: VRM
101
+ private mixer: THREE.AnimationMixer | null = null
102
+ private idleClip: THREE.AnimationClip | null = null
103
+ private idleAction: THREE.AnimationAction | null = null
104
+ private currentAction: THREE.AnimationAction | null = null
105
+ private clipCache = new Map<string, THREE.AnimationClip>()
106
+ private vmdDataCache = new Map<string, VMDAnimationData>()
107
+ private gltfLoader: GLTFLoader
108
+ private ikHandler: VRMIKHandler
109
+ private _isDancing = false
110
+ private _actionPlaying = false
111
+ private _ikActive = false
112
+ private holdTimer: ReturnType<typeof setTimeout> | null = null
113
+ private _actionSafetyTimer: ReturnType<typeof setTimeout> | null = null
114
+ private bgmAudio: HTMLAudioElement | null = null
115
+
116
+ // Callbacks for external coordination (camera switching etc.)
117
+ onDanceStart?: () => void
118
+ onDanceStop?: () => void
119
+
120
+ constructor(vrm: VRM) {
121
+ this.vrm = vrm
122
+ this.mixer = new THREE.AnimationMixer(vrm.scene)
123
+ this.gltfLoader = new GLTFLoader()
124
+ this.gltfLoader.register((parser) => new VRMAnimationLoaderPlugin(parser))
125
+ this.ikHandler = VRMIKHandler.get(vrm)
126
+ }
127
+
128
+ private _volume = 0.5
129
+
130
+ get isDancing() { return this._isDancing }
131
+ get actionPlaying() { return this._actionPlaying }
132
+
133
+ /** Set BGM volume (0–1). Also applies to currently playing BGM. */
134
+ setVolume(v: number) {
135
+ this._volume = v
136
+ if (this.bgmAudio) this.bgmAudio.volume = v
137
+ }
138
+
139
+ update(delta: number) {
140
+ if (this.mixer) this.mixer.update(delta)
141
+ if (this._ikActive) this.ikHandler.update()
142
+ }
143
+
144
+ // ── CrossFade helper ─────────────────────────────────────────────────────
145
+
146
+ private crossFadeTo(newAction: THREE.AnimationAction, duration = 0.3) {
147
+ newAction.reset().setEffectiveWeight(1).play()
148
+ const prev = this.currentAction ?? this.idleAction
149
+ if (prev && prev !== newAction) {
150
+ prev.crossFadeTo(newAction, duration, false)
151
+ }
152
+ this.currentAction = newAction
153
+ }
154
+
155
+ // ── Load & play idle animation ───────────────────────────────────────────
156
+
157
+ async loadIdle(path: string) {
158
+ const clip = await this.loadVRMA(path)
159
+ if (!clip) return
160
+ reAnchorRootPositionTrack(clip, this.vrm)
161
+ this.idleClip = clip
162
+ this.startIdle()
163
+ }
164
+
165
+ /** (Re)start idle via crossFade. */
166
+ private startIdle() {
167
+ if (!this.idleClip || !this.mixer) return
168
+ this.idleAction = this.mixer.clipAction(this.idleClip)
169
+ this.crossFadeTo(this.idleAction)
170
+ }
171
+
172
+ // ── Clear current action (private) ──────────────────────────────────────
173
+
174
+ private clearTimers() {
175
+ if (this.holdTimer) { clearTimeout(this.holdTimer); this.holdTimer = null }
176
+ if (this._actionSafetyTimer) { clearTimeout(this._actionSafetyTimer); this._actionSafetyTimer = null }
177
+ }
178
+
179
+ // ── Reset to idle (public) ──────────────────────────────────────────────
180
+ // Full reset: stop BGM + stop dance + restart idle on a fresh mixer.
181
+
182
+ resetToIdle() {
183
+ this.stopBgm()
184
+ this.clearTimers()
185
+ this.disableIK()
186
+
187
+ const wasDancing = this._isDancing
188
+ this._isDancing = false
189
+ this._actionPlaying = false
190
+ this._actionGeneration++ // invalidate any in-flight playAction
191
+
192
+ // CrossFade back to idle
193
+ if (this.mixer && this.idleClip) {
194
+ this.idleAction = this.mixer.clipAction(this.idleClip)
195
+ this.crossFadeTo(this.idleAction)
196
+ }
197
+
198
+ if (wasDancing) this.onDanceStop?.()
199
+ }
200
+
201
+ // ── Play a one-shot action ──────────────────────────────────────────────
202
+
203
+ private _actionGeneration = 0
204
+
205
+ async playAction(name: string, hold = false) {
206
+ if (this._actionPlaying) return
207
+ if (this._isDancing) return
208
+
209
+ const preset = actionPresets[name]
210
+ if (!preset) { console.warn('[Motion] unknown action:', name); return }
211
+
212
+ // Set lock BEFORE async load to prevent concurrent playAction calls
213
+ this._actionPlaying = true
214
+ const gen = ++this._actionGeneration
215
+
216
+ const clip = await this.loadClip(preset)
217
+
218
+ // Check if state was reset or another action started during await
219
+ if (gen !== this._actionGeneration) return
220
+ if (!clip) { console.warn('[Motion] clip load failed for:', name); this._actionPlaying = false; return }
221
+ if (!this.mixer) { this._actionPlaying = false; return }
222
+
223
+ this.clearTimers()
224
+
225
+ const action = this.mixer.clipAction(clip)
226
+ action.setLoop(THREE.LoopOnce, 1)
227
+ action.clampWhenFinished = true // always clamp to avoid T-pose on finish
228
+ this.crossFadeTo(action)
229
+
230
+ let settled = false
231
+ const settle = () => {
232
+ if (settled) return
233
+ settled = true
234
+ this.mixer?.removeEventListener('finished', onFinished)
235
+ // Stale settle: another action or resetToIdle already took over
236
+ if (gen !== this._actionGeneration) return
237
+ this.clearTimers()
238
+ if (hold) {
239
+ this.holdTimer = setTimeout(() => {
240
+ if (gen !== this._actionGeneration) return
241
+ this._actionPlaying = false
242
+ this.disableIK()
243
+ this.startIdle()
244
+ }, 10000)
245
+ } else {
246
+ this._actionPlaying = false
247
+ this.disableIK()
248
+ this.startIdle()
249
+ }
250
+ }
251
+ const onFinished = () => settle()
252
+ this.mixer?.addEventListener('finished', onFinished)
253
+
254
+ // Safety timeout: guarantee _actionPlaying resets even if 'finished' never fires
255
+ const duration = clip.duration > 0 ? clip.duration : 3
256
+ this._actionSafetyTimer = setTimeout(() => settle(), (duration + 1) * 1000)
257
+ }
258
+
259
+ // ── Dance (looping VMD/FBX/VRMA) ───────────────────────────────────────
260
+
261
+ async playDance(nameOrPreset: string | DancePreset) {
262
+ if (this._isDancing) return
263
+ this._isDancing = true
264
+
265
+ try {
266
+ // Accept preset name, URL, or full DancePreset object
267
+ const preset: DancePreset | undefined =
268
+ typeof nameOrPreset === 'object' ? nameOrPreset : dancePresets[nameOrPreset]
269
+ const clip = preset
270
+ ? await this.loadClip(preset)
271
+ : await this.loadClipByUrl(nameOrPreset as string)
272
+
273
+ if (!clip) {
274
+ this._isDancing = false
275
+ return
276
+ }
277
+
278
+ this.clearTimers()
279
+ this._actionPlaying = false
280
+
281
+ this.onDanceStart?.()
282
+
283
+ // Play BGM if preset has one
284
+ this.stopBgmImmediate()
285
+ if (preset?.bgm) {
286
+ this.bgmAudio = new Audio(preset.bgm)
287
+ this.bgmAudio.loop = true
288
+ this.bgmAudio.volume = this._volume
289
+ this.bgmAudio.play().catch(() => {})
290
+ }
291
+
292
+ if (!this.mixer) { this._isDancing = false; return }
293
+ const danceAction = this.mixer.clipAction(clip)
294
+ danceAction.setLoop(THREE.LoopRepeat, Infinity)
295
+ this.crossFadeTo(danceAction)
296
+ } catch (err) {
297
+ console.error('Failed to start dance:', err)
298
+ this._isDancing = false
299
+ }
300
+ }
301
+
302
+ /** Stop BGM with fade-out. Safe to call anytime. */
303
+ private stopBgm() {
304
+ if (!this.bgmAudio) return
305
+ const audio = this.bgmAudio
306
+ this.bgmAudio = null
307
+ const fadeInterval = setInterval(() => {
308
+ audio.volume = Math.max(0, audio.volume - 0.1)
309
+ if (audio.volume <= 0) {
310
+ clearInterval(fadeInterval)
311
+ audio.pause()
312
+ }
313
+ }, 50)
314
+ }
315
+
316
+ /** Stop BGM instantly without fade. Used to prevent duplicate playback. */
317
+ private stopBgmImmediate() {
318
+ if (!this.bgmAudio) return
319
+ this.bgmAudio.pause()
320
+ this.bgmAudio = null
321
+ }
322
+
323
+ /** Cleanup when controller is being destroyed (model reload etc.) */
324
+ dispose() {
325
+ this.stopBgm()
326
+ this.clearTimers()
327
+ this.disableIK()
328
+ this._isDancing = false
329
+ this._actionPlaying = false
330
+ this._actionGeneration++
331
+ if (this.mixer) {
332
+ this.mixer.stopAllAction()
333
+ this.mixer.uncacheRoot(this.vrm.scene)
334
+ this.mixer = null
335
+ }
336
+ this.idleAction = null
337
+ this.currentAction = null
338
+ }
339
+
340
+ // ── Internal ────────────────────────────────────────────────────────────
341
+
342
+ private disableIK() {
343
+ if (this._ikActive) {
344
+ this._ikActive = false
345
+ this.ikHandler.disableAll()
346
+ }
347
+ }
348
+
349
+ private async loadClip(preset: MotionPreset): Promise<THREE.AnimationClip | null> {
350
+ // For VMD, always rebuild clip with IK binding (IK targets are per-play)
351
+ if (preset.type === 'vmd') {
352
+ return this.loadVMDWithIK(preset.url)
353
+ }
354
+
355
+ const cached = this.clipCache.get(preset.url)
356
+ if (cached) return cached
357
+
358
+ let clip: THREE.AnimationClip | null = null
359
+
360
+ try {
361
+ switch (preset.type) {
362
+ case 'vrma':
363
+ clip = await this.loadVRMA(preset.url)
364
+ if (clip) reAnchorRootPositionTrack(clip, this.vrm)
365
+ break
366
+ case 'fbx':
367
+ clip = await loadMixamoAnimation(preset.url, this.vrm)
368
+ break
369
+ }
370
+ } catch (err) {
371
+ console.error('Failed to load clip:', preset.url, err)
372
+ return null
373
+ }
374
+
375
+ if (clip) {
376
+ clip.name = preset.url
377
+ this.clipCache.set(preset.url, clip)
378
+ }
379
+ return clip
380
+ }
381
+
382
+ private async loadVMDWithIK(url: string): Promise<THREE.AnimationClip | null> {
383
+ try {
384
+ // Cache parsed VMD data (parsing is expensive), but rebuild clip each time
385
+ // because IK binding creates unique target objects per play
386
+ let data = this.vmdDataCache.get(url)
387
+ if (!data) {
388
+ data = await parseVMDAnimation(url, this.vrm)
389
+ this.vmdDataCache.set(url, data)
390
+ }
391
+ const clip = bindVMDToVRM(data, this.vrm, this.ikHandler)
392
+ this._ikActive = true
393
+ return clip
394
+ } catch (err) {
395
+ console.error('Failed to load VMD:', url, err)
396
+ return null
397
+ }
398
+ }
399
+
400
+ private async loadClipByUrl(url: string): Promise<THREE.AnimationClip | null> {
401
+ const ext = url.split('.').pop()?.toLowerCase()
402
+ const type: MotionFileType =
403
+ ext === 'vmd' ? 'vmd' :
404
+ ext === 'fbx' ? 'fbx' : 'vrma'
405
+ return this.loadClip({ label: url, type, url })
406
+ }
407
+
408
+ private async loadVRMA(url: string): Promise<THREE.AnimationClip | null> {
409
+ try {
410
+ const gltf = await this.gltfLoader.loadAsync(url)
411
+ const anims = gltf.userData.vrmAnimations as VRMAnimation[]
412
+ if (anims?.length) {
413
+ return createVRMAnimationClip(anims[0], this.vrm)
414
+ }
415
+ } catch (err) {
416
+ console.warn(`Failed to load VRMA: ${url}`, err)
417
+ }
418
+ return null
419
+ }
420
+ }
friend/frontend/src/three-helpers.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Euler, MathUtils, Vector3 } from 'three'
2
+
3
+ const PI2 = Math.PI * 2
4
+
5
+ export function clampByRadian(
6
+ v: number,
7
+ min = Number.NEGATIVE_INFINITY,
8
+ max = Number.POSITIVE_INFINITY,
9
+ ) {
10
+ const hasMin = Number.isFinite(min)
11
+ const hasMax = Number.isFinite(max)
12
+ if (hasMin && hasMax && min === max) return min
13
+
14
+ const newMin = hasMin ? MathUtils.euclideanModulo(min, PI2) : min
15
+ let newMax = hasMax ? MathUtils.euclideanModulo(max, PI2) : max
16
+ let newV = MathUtils.euclideanModulo(v, PI2)
17
+
18
+ if (hasMin && hasMax && newMin >= newMax) {
19
+ newMax += PI2
20
+ if (newV < Math.PI) newV += PI2
21
+ }
22
+ if (hasMax && newV > newMax) newV = newMax
23
+ else if (hasMin && newV < newMin) newV = newMin
24
+ return MathUtils.euclideanModulo(newV, PI2)
25
+ }
26
+
27
+ export function clampVector3ByRadian(v: Vector3 | Euler, min?: Vector3, max?: Vector3) {
28
+ return v.set(
29
+ clampByRadian(v.x, min?.x, max?.x),
30
+ clampByRadian(v.y, min?.y, max?.y),
31
+ clampByRadian(v.z, min?.z, max?.z),
32
+ )
33
+ }
friend/frontend/src/vmd-loader.ts ADDED
@@ -0,0 +1,663 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * VMD animation loader for VRM models.
3
+ * Ported from lobe-vidol's VMDAnimation module.
4
+ *
5
+ * Parses VMD binary files (via mmd-parser), converts bone/morph keyframes
6
+ * to VRM-compatible AnimationClips with IK support.
7
+ */
8
+
9
+ import {
10
+ VRMExpressionPresetName as BlendShapePresetName,
11
+ VRMHumanBoneName as HumanoidBoneName,
12
+ } from '@pixiv/three-vrm-core'
13
+ import type { VRM, VRMHumanBoneName } from '@pixiv/three-vrm'
14
+ import { CharsetEncoder, Parser } from 'mmd-parser'
15
+ import type { VmdFile } from 'mmd-parser'
16
+ import {
17
+ AnimationClip,
18
+ KeyframeTrack,
19
+ MathUtils,
20
+ NumberKeyframeTrack,
21
+ Object3D,
22
+ Quaternion,
23
+ QuaternionKeyframeTrack,
24
+ Vector3,
25
+ VectorKeyframeTrack,
26
+ } from 'three'
27
+
28
+ import { VRMIKHandler } from './vrm-ik-handler'
29
+
30
+ // ── Types ───────────────────────────────────────────────────────────────────
31
+
32
+ export interface VMDAnimationData {
33
+ duration: number
34
+ timelines: Timeline[]
35
+ }
36
+
37
+ interface Timeline {
38
+ name: VRMHumanBoneName | BlendShapePresetName
39
+ type: 'position' | 'rotation' | 'morph'
40
+ isIK?: boolean
41
+ times: number[]
42
+ values: number[]
43
+ }
44
+
45
+ interface VRMOffsets {
46
+ hipsOffset?: number[]
47
+ leftFootOffset?: number[]
48
+ leftToeOffset?: number[]
49
+ rightFootOffset?: number[]
50
+ rightToeOffset?: number[]
51
+ }
52
+
53
+ interface Keyframe {
54
+ boneName: string
55
+ frameNum: number
56
+ position: Vector3
57
+ rotation: Quaternion
58
+ }
59
+
60
+ interface LerpKeyframe extends Keyframe {
61
+ isNew?: boolean
62
+ }
63
+
64
+ interface IKOffsetInit {
65
+ x: number
66
+ y: number
67
+ z: number
68
+ s?: number
69
+ sx?: number
70
+ sy?: number
71
+ sz?: number
72
+ o?: number
73
+ ox?: number
74
+ oy?: number
75
+ oz?: number
76
+ dx?: boolean
77
+ dy?: boolean
78
+ dz?: boolean
79
+ }
80
+
81
+ // ── Hot-patch CharsetEncoder to forgive invalid charcodes ───────────────────
82
+
83
+ {
84
+ const { s2u } = CharsetEncoder.prototype
85
+ CharsetEncoder.prototype.s2u = function (array: any) {
86
+ try {
87
+ return s2u.call(this, array)
88
+ } catch (e) {
89
+ if (typeof e === 'string') {
90
+ console.warn(`Charset encoder: ${e}`)
91
+ return ''
92
+ }
93
+ throw e
94
+ }
95
+ }
96
+ }
97
+
98
+ // ── Constants ───────────────────────────────────────────────────────────────
99
+
100
+ function isTruely<T>(x: T): x is Exclude<T, false | '' | 0 | null | undefined> {
101
+ return !!x
102
+ }
103
+
104
+ const tempV3 = new Vector3()
105
+ const tempQ = new Quaternion()
106
+
107
+ const VMDBoneNames = {
108
+ Root: '全ての親',
109
+ Center: 'センター',
110
+ Hips: '下半身',
111
+ Spine: '上半身',
112
+ Chest: '上半身2',
113
+ Neck: '首',
114
+ Head: '頭',
115
+ LeftEye: '左目',
116
+ LeftShoulder: '左肩',
117
+ LeftUpperArm: '左腕',
118
+ LeftLowerArm: '左ひじ',
119
+ LeftHand: '左手首',
120
+ LeftThumbProximal: '左親指0',
121
+ LeftThumbIntermediate: '左親指1',
122
+ LeftThumbDistal: '左親指2',
123
+ LeftIndexProximal: '左人指1',
124
+ LeftIndexIntermediate: '左人指2',
125
+ LeftIndexDistal: '左人指3',
126
+ LeftMiddleProximal: '左中指1',
127
+ LeftMiddleIntermediate: '左中指2',
128
+ LeftMiddleDistal: '左中指3',
129
+ LeftRingProximal: '左薬指1',
130
+ LeftRingIntermediate: '左薬指2',
131
+ LeftRingDistal: '左薬指3',
132
+ LeftLittleProximal: '左小指1',
133
+ LeftLittleIntermediate: '左小指2',
134
+ LeftLittleDistal: '左小指3',
135
+ LeftUpperLeg: '左足',
136
+ LeftLowerLeg: '左ひざ',
137
+ LeftFoot: '左足首',
138
+ LeftFootIK: '左足IK',
139
+ LeftToes: '左つま先',
140
+ LeftToeIK: '左つま先IK',
141
+ RightEye: '右目',
142
+ RightShoulder: '右肩',
143
+ RightUpperArm: '右腕',
144
+ RightLowerArm: '右ひじ',
145
+ RightHand: '右手首',
146
+ RightThumbProximal: '右親指0',
147
+ RightThumbIntermediate: '右親指1',
148
+ RightThumbDistal: '右親指2',
149
+ RightIndexProximal: '右人指1',
150
+ RightIndexIntermediate: '右人指2',
151
+ RightIndexDistal: '右人指3',
152
+ RightMiddleProximal: '右中指1',
153
+ RightMiddleIntermediate: '右中指2',
154
+ RightMiddleDistal: '右中指3',
155
+ RightRingProximal: '右薬指1',
156
+ RightRingIntermediate: '右薬指2',
157
+ RightRingDistal: '右薬指3',
158
+ RightLittleProximal: '右小指1',
159
+ RightLittleIntermediate: '右小指2',
160
+ RightLittleDistal: '右小指3',
161
+ RightUpperLeg: '右足',
162
+ RightLowerLeg: '右ひざ',
163
+ RightFoot: '右足首',
164
+ RightFootIK: '右足IK',
165
+ RightToes: '右つま先',
166
+ RightToeIK: '右つま先IK',
167
+ } as const
168
+
169
+ const VMDMorphNames = {
170
+ Blink: 'まばたき',
171
+ BlinkR: 'ウィンク',
172
+ BlinkL: 'ウィンク右',
173
+ A: 'あ',
174
+ I: 'い',
175
+ U: 'う',
176
+ E: 'え',
177
+ O: 'お',
178
+ } as const
179
+
180
+ const VRM_VMD_BONE_MAP = new Map<string, HumanoidBoneName>([
181
+ [VMDBoneNames.Hips, HumanoidBoneName.Hips],
182
+ [VMDBoneNames.Spine, HumanoidBoneName.Spine],
183
+ [VMDBoneNames.Chest, HumanoidBoneName.Chest],
184
+ [VMDBoneNames.Neck, HumanoidBoneName.Neck],
185
+ [VMDBoneNames.Head, HumanoidBoneName.Head],
186
+ [VMDBoneNames.LeftEye, HumanoidBoneName.LeftEye],
187
+ [VMDBoneNames.LeftShoulder, HumanoidBoneName.LeftShoulder],
188
+ [VMDBoneNames.LeftUpperArm, HumanoidBoneName.LeftUpperArm],
189
+ [VMDBoneNames.LeftLowerArm, HumanoidBoneName.LeftLowerArm],
190
+ [VMDBoneNames.LeftHand, HumanoidBoneName.LeftHand],
191
+ [VMDBoneNames.LeftThumbProximal, HumanoidBoneName.LeftThumbProximal],
192
+ [VMDBoneNames.LeftThumbIntermediate, HumanoidBoneName.LeftThumbMetacarpal],
193
+ [VMDBoneNames.LeftThumbDistal, HumanoidBoneName.LeftThumbDistal],
194
+ [VMDBoneNames.LeftIndexProximal, HumanoidBoneName.LeftIndexProximal],
195
+ [VMDBoneNames.LeftIndexIntermediate, HumanoidBoneName.LeftIndexIntermediate],
196
+ [VMDBoneNames.LeftIndexDistal, HumanoidBoneName.LeftIndexDistal],
197
+ [VMDBoneNames.LeftMiddleProximal, HumanoidBoneName.LeftMiddleProximal],
198
+ [VMDBoneNames.LeftMiddleIntermediate, HumanoidBoneName.LeftMiddleIntermediate],
199
+ [VMDBoneNames.LeftMiddleDistal, HumanoidBoneName.LeftMiddleDistal],
200
+ [VMDBoneNames.LeftRingProximal, HumanoidBoneName.LeftRingProximal],
201
+ [VMDBoneNames.LeftRingIntermediate, HumanoidBoneName.LeftRingIntermediate],
202
+ [VMDBoneNames.LeftRingDistal, HumanoidBoneName.LeftRingDistal],
203
+ [VMDBoneNames.LeftLittleProximal, HumanoidBoneName.LeftLittleProximal],
204
+ [VMDBoneNames.LeftLittleIntermediate, HumanoidBoneName.LeftLittleIntermediate],
205
+ [VMDBoneNames.LeftLittleDistal, HumanoidBoneName.LeftLittleDistal],
206
+ [VMDBoneNames.LeftUpperLeg, HumanoidBoneName.LeftUpperLeg],
207
+ [VMDBoneNames.LeftLowerLeg, HumanoidBoneName.LeftLowerLeg],
208
+ [VMDBoneNames.LeftFoot, HumanoidBoneName.LeftFoot],
209
+ [VMDBoneNames.LeftToes, HumanoidBoneName.LeftToes],
210
+ [VMDBoneNames.RightEye, HumanoidBoneName.RightEye],
211
+ [VMDBoneNames.RightShoulder, HumanoidBoneName.RightShoulder],
212
+ [VMDBoneNames.RightUpperArm, HumanoidBoneName.RightUpperArm],
213
+ [VMDBoneNames.RightLowerArm, HumanoidBoneName.RightLowerArm],
214
+ [VMDBoneNames.RightHand, HumanoidBoneName.RightHand],
215
+ [VMDBoneNames.RightThumbProximal, HumanoidBoneName.RightThumbProximal],
216
+ [VMDBoneNames.RightThumbIntermediate, HumanoidBoneName.RightThumbMetacarpal],
217
+ [VMDBoneNames.RightThumbDistal, HumanoidBoneName.RightThumbDistal],
218
+ [VMDBoneNames.RightIndexProximal, HumanoidBoneName.RightIndexProximal],
219
+ [VMDBoneNames.RightIndexIntermediate, HumanoidBoneName.RightIndexIntermediate],
220
+ [VMDBoneNames.RightIndexDistal, HumanoidBoneName.RightIndexDistal],
221
+ [VMDBoneNames.RightMiddleProximal, HumanoidBoneName.RightMiddleProximal],
222
+ [VMDBoneNames.RightMiddleIntermediate, HumanoidBoneName.RightMiddleIntermediate],
223
+ [VMDBoneNames.RightMiddleDistal, HumanoidBoneName.RightMiddleDistal],
224
+ [VMDBoneNames.RightRingProximal, HumanoidBoneName.RightRingProximal],
225
+ [VMDBoneNames.RightRingIntermediate, HumanoidBoneName.RightRingIntermediate],
226
+ [VMDBoneNames.RightRingDistal, HumanoidBoneName.RightRingDistal],
227
+ [VMDBoneNames.RightLittleProximal, HumanoidBoneName.RightLittleProximal],
228
+ [VMDBoneNames.RightLittleIntermediate, HumanoidBoneName.RightLittleIntermediate],
229
+ [VMDBoneNames.RightLittleDistal, HumanoidBoneName.RightLittleDistal],
230
+ [VMDBoneNames.RightUpperLeg, HumanoidBoneName.RightUpperLeg],
231
+ [VMDBoneNames.RightLowerLeg, HumanoidBoneName.RightLowerLeg],
232
+ [VMDBoneNames.RightFoot, HumanoidBoneName.RightFoot],
233
+ [VMDBoneNames.RightToes, HumanoidBoneName.RightToes],
234
+ ])
235
+
236
+ const VMD_VRM_IK_MAP = new Map<string, HumanoidBoneName>([
237
+ [VMDBoneNames.LeftFootIK, HumanoidBoneName.LeftFoot],
238
+ [VMDBoneNames.LeftToeIK, HumanoidBoneName.LeftToes],
239
+ [VMDBoneNames.RightFootIK, HumanoidBoneName.RightFoot],
240
+ [VMDBoneNames.RightToeIK, HumanoidBoneName.RightToes],
241
+ ])
242
+
243
+ const VMD_BONE_NAMES = new Set<string>(VRM_VMD_BONE_MAP.keys())
244
+ Array.from(VMD_VRM_IK_MAP.keys()).forEach(VMD_BONE_NAMES.add, VMD_BONE_NAMES)
245
+ VMD_BONE_NAMES.add(VMDBoneNames.Root)
246
+ VMD_BONE_NAMES.add(VMDBoneNames.Center)
247
+
248
+ const VMD_VRM_MORPH_MAP = new Map<string, BlendShapePresetName>([
249
+ [VMDMorphNames.Blink, BlendShapePresetName.Blink],
250
+ [VMDMorphNames.BlinkL, BlendShapePresetName.BlinkLeft],
251
+ [VMDMorphNames.BlinkR, BlendShapePresetName.BlinkRight],
252
+ [VMDMorphNames.A, BlendShapePresetName.Aa],
253
+ [VMDMorphNames.I, BlendShapePresetName.Ih],
254
+ [VMDMorphNames.U, BlendShapePresetName.Ou],
255
+ [VMDMorphNames.E, BlendShapePresetName.Ee],
256
+ [VMDMorphNames.O, BlendShapePresetName.Oh],
257
+ ])
258
+
259
+ const IK_OFFSET_INIT = new Map<string, IKOffsetInit>([
260
+ [VMDBoneNames.Center, { x: 0, y: 1, z: 0, s: 10 }],
261
+ [VMDBoneNames.LeftFootIK, { x: 1, y: 1, z: 0, s: 10, dx: true }],
262
+ [VMDBoneNames.RightFootIK, { x: -1, y: 1, z: 0, s: 10, dx: true }],
263
+ [VMDBoneNames.LeftToeIK, { x: 0, y: -1, z: -1, s: 10, oy: 2.5, dx: true, dz: true }],
264
+ [VMDBoneNames.RightToeIK, { x: 0, y: -1, z: -1, s: 10, oy: 2.5, dx: true, dz: true }],
265
+ ])
266
+
267
+ const V3_ZERO = new Vector3()
268
+ const Q_IDENTITY = new Quaternion()
269
+ const Z_30_DEG_CW = new Quaternion().setFromAxisAngle(tempV3.set(0, 0, 1), 30 * MathUtils.DEG2RAD)
270
+ const Z_30_DEG_CCW = Z_30_DEG_CW.clone().invert()
271
+
272
+ // ── VMD Parsing ────────��────────────────────────────────────────────────────
273
+
274
+ function convertVMD(buffer: ArrayBufferLike, vrmOffset?: VRMOffsets): VMDAnimationData {
275
+ const vmd = new Parser().parseVmd(buffer)
276
+ const morphs = convertMorphs(vmd)
277
+ const motions = convertMotions(vmd, vrmOffset)
278
+ return {
279
+ duration: Math.max(morphs.duration, motions.duration),
280
+ timelines: Array.prototype.concat(morphs.timelines, motions.timelines),
281
+ }
282
+ }
283
+
284
+ function convertMorphs({ morphs }: VmdFile): VMDAnimationData {
285
+ sortFrames(morphs)
286
+ const timelines = new Map<string, Timeline>()
287
+ for (const { morphName, weight, frameNum } of morphs) {
288
+ const name = VMD_VRM_MORPH_MAP.get(morphName)
289
+ if (!name) continue
290
+ let timeline = timelines.get(name)
291
+ if (!timeline)
292
+ timelines.set(name, (timeline = { name, type: 'morph', times: [], values: [] }))
293
+ const { times, values } = timeline
294
+ const time = frameNum / 30
295
+ const timeIndex = times.findIndex((t) => t === time)
296
+ if (timeIndex < 0) {
297
+ times.push(time)
298
+ values.push(weight)
299
+ } else {
300
+ values[timeIndex] = Math.max(values[timeIndex], weight)
301
+ }
302
+ }
303
+ return {
304
+ timelines: Array.from(timelines.values()),
305
+ duration: getLastFrameNum(morphs) / 30,
306
+ }
307
+ }
308
+
309
+ function convertMotions({ motions }: VmdFile, vrmOffset?: VRMOffsets): VMDAnimationData {
310
+ sortFrames(motions)
311
+ const timelines: Timeline[] = []
312
+ const boneTlMap = new Map<string, Keyframe[]>()
313
+ for (const name of VMD_BONE_NAMES) boneTlMap.set(name, [])
314
+ for (const { boneName, frameNum, position, rotation } of motions)
315
+ boneTlMap.get(boneName)?.push({
316
+ boneName,
317
+ frameNum,
318
+ position: new Vector3().fromArray(position),
319
+ rotation: new Quaternion().fromArray(rotation),
320
+ })
321
+ fixPositions(boneTlMap, vrmOffset)
322
+ for (const [boneName, timeline] of boneTlMap) {
323
+ let name = VRM_VMD_BONE_MAP.get(boneName)
324
+ let isIK = false
325
+ if (!name) {
326
+ isIK = VMD_VRM_IK_MAP.has(boneName)
327
+ name = VMD_VRM_IK_MAP.get(boneName)
328
+ }
329
+ if (name) {
330
+ const times: number[] = []
331
+ const positions: number[] = []
332
+ const rotations: number[] = []
333
+ for (const f of timeline) {
334
+ const i = times.push(f.frameNum / 30) - 1
335
+ f.position.toArray(positions, i * 3)
336
+ f.rotation.toArray(rotations, i * 4)
337
+ }
338
+ if (times.length) {
339
+ timelines.push({ name, type: 'rotation', isIK, times, values: rotations })
340
+ if (isIK || name === HumanoidBoneName.Hips)
341
+ timelines.push({ name, type: 'position', isIK, times, values: positions })
342
+ }
343
+ }
344
+ }
345
+ return { timelines, duration: getLastFrameNum(motions) / 30 }
346
+ }
347
+
348
+ function fixPositions(tls: Map<string, Keyframe[]>, vrmOffset: VRMOffsets = {}) {
349
+ const centerOffset = mergeTimelines(
350
+ tls,
351
+ VMDBoneNames.Center,
352
+ offsetToTimeline(VMDBoneNames.Center, vrmOffset.hipsOffset),
353
+ )
354
+ const hipsTl = mergeTimelines(tls, VMDBoneNames.Root, centerOffset, VMDBoneNames.Hips)
355
+ tls.set(
356
+ VMDBoneNames.Spine,
357
+ localizeTimeline(
358
+ hipsTl,
359
+ mergeTimelines(tls, VMDBoneNames.Root, centerOffset, VMDBoneNames.Hips),
360
+ ),
361
+ )
362
+ tls.set(VMDBoneNames.Hips, hipsTl)
363
+ const leftFootOffset = offsetToTimeline(VMDBoneNames.LeftFootIK, vrmOffset.leftFootOffset)
364
+ const rightFootOffset = offsetToTimeline(VMDBoneNames.RightFootIK, vrmOffset.rightFootOffset)
365
+ if (tls.has(VMDBoneNames.LeftToeIK))
366
+ tls.set(
367
+ VMDBoneNames.LeftToeIK,
368
+ mergeTimelines(
369
+ tls,
370
+ VMDBoneNames.Root,
371
+ leftFootOffset,
372
+ VMDBoneNames.LeftFootIK,
373
+ offsetToTimeline(VMDBoneNames.RightToeIK, vrmOffset.leftToeOffset),
374
+ VMDBoneNames.LeftToeIK,
375
+ ),
376
+ )
377
+ if (tls.has(VMDBoneNames.RightToeIK))
378
+ tls.set(
379
+ VMDBoneNames.RightToeIK,
380
+ mergeTimelines(
381
+ tls,
382
+ VMDBoneNames.Root,
383
+ rightFootOffset,
384
+ VMDBoneNames.RightFootIK,
385
+ offsetToTimeline(VMDBoneNames.RightToeIK, vrmOffset.rightToeOffset),
386
+ VMDBoneNames.RightToeIK,
387
+ ),
388
+ )
389
+ if (tls.has(VMDBoneNames.LeftFootIK))
390
+ tls.set(
391
+ VMDBoneNames.LeftFootIK,
392
+ mergeTimelines(tls, VMDBoneNames.Root, leftFootOffset, VMDBoneNames.LeftFootIK),
393
+ )
394
+ if (tls.has(VMDBoneNames.RightFootIK))
395
+ tls.set(
396
+ VMDBoneNames.RightFootIK,
397
+ mergeTimelines(tls, VMDBoneNames.Root, rightFootOffset, VMDBoneNames.RightFootIK),
398
+ )
399
+ tls.delete(VMDBoneNames.Center)
400
+ tls.delete(VMDBoneNames.Root)
401
+ for (const tl of tls.values())
402
+ for (const f of tl) {
403
+ f.position.x *= -1
404
+ f.rotation.x *= -1
405
+ f.rotation.w *= -1
406
+ switch (f.boneName) {
407
+ case VMDBoneNames.LeftUpperArm:
408
+ f.rotation.multiply(Z_30_DEG_CW)
409
+ break
410
+ case VMDBoneNames.RightUpperArm:
411
+ f.rotation.multiply(Z_30_DEG_CCW)
412
+ break
413
+ case VMDBoneNames.LeftLowerArm:
414
+ case VMDBoneNames.LeftHand:
415
+ case VMDBoneNames.LeftThumbProximal:
416
+ case VMDBoneNames.LeftThumbIntermediate:
417
+ case VMDBoneNames.LeftThumbDistal:
418
+ case VMDBoneNames.LeftIndexProximal:
419
+ case VMDBoneNames.LeftIndexIntermediate:
420
+ case VMDBoneNames.LeftIndexDistal:
421
+ case VMDBoneNames.LeftMiddleProximal:
422
+ case VMDBoneNames.LeftMiddleIntermediate:
423
+ case VMDBoneNames.LeftMiddleDistal:
424
+ case VMDBoneNames.LeftRingProximal:
425
+ case VMDBoneNames.LeftRingIntermediate:
426
+ case VMDBoneNames.LeftRingDistal:
427
+ case VMDBoneNames.LeftLittleProximal:
428
+ case VMDBoneNames.LeftLittleIntermediate:
429
+ case VMDBoneNames.LeftLittleDistal:
430
+ f.rotation.premultiply(Z_30_DEG_CCW).multiply(Z_30_DEG_CW)
431
+ break
432
+ case VMDBoneNames.RightLowerArm:
433
+ case VMDBoneNames.RightHand:
434
+ case VMDBoneNames.RightThumbProximal:
435
+ case VMDBoneNames.RightThumbIntermediate:
436
+ case VMDBoneNames.RightThumbDistal:
437
+ case VMDBoneNames.RightIndexProximal:
438
+ case VMDBoneNames.RightIndexIntermediate:
439
+ case VMDBoneNames.RightIndexDistal:
440
+ case VMDBoneNames.RightMiddleProximal:
441
+ case VMDBoneNames.RightMiddleIntermediate:
442
+ case VMDBoneNames.RightMiddleDistal:
443
+ case VMDBoneNames.RightRingProximal:
444
+ case VMDBoneNames.RightRingIntermediate:
445
+ case VMDBoneNames.RightRingDistal:
446
+ case VMDBoneNames.RightLittleProximal:
447
+ case VMDBoneNames.RightLittleIntermediate:
448
+ case VMDBoneNames.RightLittleDistal:
449
+ f.rotation.premultiply(Z_30_DEG_CW).multiply(Z_30_DEG_CCW)
450
+ break
451
+ }
452
+ f.position.multiplyScalar(0.1)
453
+ }
454
+ }
455
+
456
+ function offsetToTimeline(boneName: string, rawPos: number[] | undefined): Keyframe[] {
457
+ const init = IK_OFFSET_INIT.get(boneName)!
458
+ return [
459
+ {
460
+ boneName: `${boneName}Offset`,
461
+ frameNum: 0,
462
+ position: rawPos
463
+ ? new Vector3(
464
+ init.dx || isNaN(rawPos[0])
465
+ ? init.x
466
+ : rawPos[0] * (init.sx ?? 1) * (init.s ?? 1) + (init.ox ?? 0) + (init.o ?? 0),
467
+ init.dy || isNaN(rawPos[1])
468
+ ? init.y
469
+ : rawPos[1] * (init.sy ?? 1) * (init.s ?? 1) + (init.oy ?? 0) + (init.o ?? 0),
470
+ init.dz || isNaN(rawPos[2])
471
+ ? init.z
472
+ : rawPos[2] * (init.sz ?? 1) * (init.s ?? 1) + (init.oz ?? 0) + (init.o ?? 0),
473
+ )
474
+ : new Vector3(init.x, init.y, init.z),
475
+ rotation: Q_IDENTITY,
476
+ },
477
+ ]
478
+ }
479
+
480
+ function mergeTimelines(tlsMap: Map<string, Keyframe[]>, ...tlsKey: (Keyframe[] | string)[]) {
481
+ const tls = tlsKey.map(resolveTimeline, tlsMap).filter(isTruely)
482
+ let boneName: string
483
+ const last = tlsKey[tlsKey.length - 1]
484
+ if (typeof last === 'string') boneName = last
485
+ else boneName = tls[tls.length - 1][0]?.boneName ?? ''
486
+ const results: Keyframe[] = []
487
+ for (const tl of tls)
488
+ for (const f of tl) {
489
+ const { frameNum } = f
490
+ if (frameNum < results.length && results[frameNum] != null) continue
491
+ const position = new Vector3()
492
+ const rotation = new Quaternion()
493
+ for (const otl of tls) {
494
+ if (!otl.length) continue
495
+ const f2 = otl[0].boneName === f.boneName ? f : lerpKeyframe(otl, frameNum)
496
+ position.add(tempV3.copy(f2.position).applyQuaternion(rotation))
497
+ rotation.multiply(f2.rotation)
498
+ }
499
+ results[frameNum] = { boneName, frameNum, position, rotation }
500
+ }
501
+ return results.filter(isTruely)
502
+ }
503
+
504
+ function localizeTimeline(parent: Keyframe[], child: Keyframe[]): Keyframe[]
505
+ function localizeTimeline(...tls: [Keyframe[], Keyframe[]]) {
506
+ const { boneName } = tls[1][0]
507
+ const results: Keyframe[] = []
508
+ let isChild = false
509
+ for (const tl of tls) {
510
+ for (const f of tl) {
511
+ const { frameNum } = f
512
+ if (frameNum < results.length && results[frameNum] != null) continue
513
+ const fp: LerpKeyframe = isChild ? lerpKeyframe(tls[0], frameNum) : f
514
+ const fc: LerpKeyframe = isChild ? f : lerpKeyframe(tls[1], frameNum)
515
+ results[frameNum] = {
516
+ boneName,
517
+ frameNum,
518
+ position: (fc.isNew ? fc.position : fc.position.clone()).sub(fp.position),
519
+ rotation: (fc.isNew ? fc.rotation : fc.rotation.clone()).multiply(
520
+ tempQ.copy(fp.rotation).invert(),
521
+ ),
522
+ }
523
+ }
524
+ isChild = true
525
+ }
526
+ return results.filter(isTruely)
527
+ }
528
+
529
+ function lerpKeyframe(tl: Keyframe[], frameNum: number): LerpKeyframe {
530
+ if (!tl)
531
+ return { boneName: '', frameNum, position: V3_ZERO, rotation: Q_IDENTITY }
532
+ const nextIndex = tl.findIndex((keyframe) => frameNum < keyframe.frameNum)
533
+ switch (nextIndex) {
534
+ case 0:
535
+ return tl[0]
536
+ case -1:
537
+ return tl[tl.length - 1]
538
+ case frameNum:
539
+ return tl[frameNum]
540
+ }
541
+ const prevFrame = tl[nextIndex - 1]
542
+ const nextFrame = tl[nextIndex]
543
+ const prevFrameNum = prevFrame.frameNum
544
+ const nextFrameNum = nextFrame.frameNum
545
+ const v = (frameNum - prevFrameNum) / (nextFrameNum - prevFrameNum)
546
+ return {
547
+ boneName: tl[0].boneName,
548
+ frameNum,
549
+ position: prevFrame.position.clone().lerp(nextFrame.position, v),
550
+ rotation: prevFrame.rotation.clone().slerp(nextFrame.rotation, v),
551
+ isNew: true,
552
+ }
553
+ }
554
+
555
+ function resolveTimeline(this: Map<string, Keyframe[]>, key: Keyframe[] | string) {
556
+ return Array.isArray(key) ? key : this.get(key)
557
+ }
558
+
559
+ function sortFrames<T extends { frameNum: number }>(f: ArrayLike<T> | Iterable<T>) {
560
+ return (Array.isArray(f) ? (f as T[]) : Array.from(f)).sort((a, b) => a.frameNum - b.frameNum)
561
+ }
562
+
563
+ function getLastFrameNum(f: { frameNum: number }[]) {
564
+ return f.length ? f[f.length - 1].frameNum : 0
565
+ }
566
+
567
+ // ── VRM Offset Calculation ──────────────────────────────────────────────────
568
+
569
+ function calculatePosition(from?: Object3D | null, to?: Object3D | null) {
570
+ if (!from || !to) return
571
+ let current: Object3D | null = to
572
+ const chain: Object3D[] = [to]
573
+ while (current.parent && current !== from) {
574
+ chain.push(current.parent)
575
+ current = current.parent
576
+ }
577
+ if (current === null) return
578
+ chain.reverse()
579
+ const position = tempV3.set(0, 0, 0)
580
+ for (const node of chain) position.add(node.position)
581
+ return position.toArray()
582
+ }
583
+
584
+ function toOffset(vrm: VRM): VRMOffsets {
585
+ const { humanoid } = vrm
586
+ if (!humanoid) throw new Error('VRM does not have humanoid')
587
+ const currentPose = humanoid.getNormalizedPose()
588
+ humanoid.resetNormalizedPose()
589
+ const hips = humanoid.getNormalizedBoneNode(HumanoidBoneName.Hips)
590
+ const leftFoot = humanoid.getNormalizedBoneNode(HumanoidBoneName.LeftFoot)
591
+ const leftToe = humanoid.getNormalizedBoneNode(HumanoidBoneName.LeftToes)
592
+ const rightFoot = humanoid.getNormalizedBoneNode(HumanoidBoneName.RightFoot)
593
+ const rightToe = humanoid.getNormalizedBoneNode(HumanoidBoneName.RightToes)
594
+ humanoid.setNormalizedPose(currentPose)
595
+ return {
596
+ hipsOffset: calculatePosition(hips, hips),
597
+ leftFootOffset: calculatePosition(hips, leftFoot),
598
+ leftToeOffset: calculatePosition(leftFoot, leftToe),
599
+ rightFootOffset: calculatePosition(hips, rightFoot),
600
+ rightToeOffset: calculatePosition(rightFoot, rightToe),
601
+ }
602
+ }
603
+
604
+ // ── VRM Binding ─────────────────────────────────────────────────────────────
605
+
606
+ function bindToVRM(data: VMDAnimationData, vrm: VRM): AnimationClip {
607
+ const tracks: KeyframeTrack[] = []
608
+ for (const { type, name, isIK, times, values } of data.timelines) {
609
+ let srcName: string
610
+ switch (type) {
611
+ case 'morph': {
612
+ const track = vrm.expressionManager?.getExpressionTrackName(name as BlendShapePresetName)
613
+ if (!track) continue
614
+ srcName = track
615
+ break
616
+ }
617
+ case 'position':
618
+ case 'rotation': {
619
+ if (isIK) {
620
+ const handler = VRMIKHandler.get(vrm)
621
+ const target = handler.getAndEnableIK(name as VRMHumanBoneName)
622
+ if (!target) continue
623
+ srcName = target.name
624
+ } else {
625
+ const bone = vrm.humanoid?.getNormalizedBone(name as VRMHumanBoneName)
626
+ if (!bone) continue
627
+ srcName = bone.node.name
628
+ }
629
+ break
630
+ }
631
+ default:
632
+ continue
633
+ }
634
+ switch (type) {
635
+ case 'morph':
636
+ tracks.push(new NumberKeyframeTrack(srcName, times, values))
637
+ break
638
+ case 'position':
639
+ tracks.push(new VectorKeyframeTrack(`${srcName}.position`, times, values))
640
+ break
641
+ case 'rotation':
642
+ tracks.push(new QuaternionKeyframeTrack(`${srcName}.quaternion`, times, values))
643
+ break
644
+ }
645
+ }
646
+ return new AnimationClip(`clip${Date.now()}`, data.duration, tracks)
647
+ }
648
+
649
+ // ── Public API (matches motion-controller imports) ──────────────────────────
650
+
651
+ export async function parseVMDAnimation(url: string, vrm: VRM): Promise<VMDAnimationData> {
652
+ const res = await fetch(url)
653
+ const buffer = await res.arrayBuffer()
654
+ return convertVMD(buffer, toOffset(vrm))
655
+ }
656
+
657
+ export function bindVMDToVRM(
658
+ data: VMDAnimationData,
659
+ vrm: VRM,
660
+ _ikHandler: VRMIKHandler,
661
+ ): AnimationClip {
662
+ return bindToVRM(data, vrm)
663
+ }