feat: /friend
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +5 -5
- adapters/common/session-store.js +7 -0
- adapters/wechat/protocol.js +10 -0
- friend/frontend/index.html +102 -0
- friend/frontend/package-lock.json +2372 -0
- friend/frontend/package.json +30 -0
- friend/frontend/public/akimbo.vrma +3 -0
- friend/frontend/public/angry.fbx +3 -0
- friend/frontend/public/angryPump.fbx +3 -0
- friend/frontend/public/excited.fbx +3 -0
- friend/frontend/public/greeting.fbx +3 -0
- friend/frontend/public/happy.fbx +3 -0
- friend/frontend/public/idle_loop.vrma +3 -0
- friend/frontend/public/jile.vmd +3 -0
- friend/frontend/public/love.vmd +3 -0
- friend/frontend/public/playFingers.vrma +3 -0
- friend/frontend/public/point.fbx +3 -0
- friend/frontend/public/salute.fbx +3 -0
- friend/frontend/public/scratchHead.vrma +3 -0
- friend/frontend/public/shy.fbx +3 -0
- friend/frontend/public/stretch.vrma +3 -0
- friend/frontend/src-tauri/Cargo.lock +0 -0
- friend/frontend/src-tauri/Cargo.toml +23 -0
- friend/frontend/src-tauri/build.rs +3 -0
- friend/frontend/src-tauri/capabilities/default.json +9 -0
- friend/frontend/src-tauri/icons/128x128.png +3 -0
- friend/frontend/src-tauri/icons/256x256.png +3 -0
- friend/frontend/src-tauri/icons/32x32.png +3 -0
- friend/frontend/src-tauri/src/lib.rs +6 -0
- friend/frontend/src-tauri/src/main.rs +6 -0
- friend/frontend/src-tauri/tauri.conf.json +40 -0
- friend/frontend/src/App.tsx +540 -0
- friend/frontend/src/api.ts +51 -0
- friend/frontend/src/assets/lip-sync-profile.json +1 -0
- friend/frontend/src/components/ChatInput.tsx +672 -0
- friend/frontend/src/components/HistoryPanel.tsx +177 -0
- friend/frontend/src/components/MoodIndicator.tsx +372 -0
- friend/frontend/src/components/ResizeHandles.tsx +7 -0
- friend/frontend/src/components/SettingsPanel.tsx +1020 -0
- friend/frontend/src/components/TextBubble.tsx +571 -0
- friend/frontend/src/components/VRMScene.tsx +870 -0
- friend/frontend/src/emote.ts +206 -0
- friend/frontend/src/hooks/usePassThrough.ts +12 -0
- friend/frontend/src/lip-sync.ts +187 -0
- friend/frontend/src/main.tsx +9 -0
- friend/frontend/src/mixamo-loader.ts +141 -0
- friend/frontend/src/mmd-parser.d.ts +39 -0
- friend/frontend/src/motion-controller.ts +420 -0
- friend/frontend/src/three-helpers.ts +33 -0
- 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 |
-
#
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 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
|
friend/frontend/src-tauri/icons/256x256.png
ADDED
|
|
Git LFS Details
|
friend/frontend/src-tauri/icons/32x32.png
ADDED
|
|
Git LFS Details
|
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 |
+
}
|