Compare commits

..

15 Commits

Author SHA1 Message Date
8f2ff558eb Refactor Live2D plugin and renderer for improved clarity and performance
- Updated EGL and OpenGL ES integration in gl.rs for better context management.
- Simplified error handling and logging in model.rs and plugin.rs.
- Enhanced texture handling and rendering logic in renderer.rs.
- Added a script to find Cargo configuration files for easier debugging.
- Removed unused imports and dead code to clean up the codebase.
2026-07-07 16:47:28 +08:00
86e36aa677 feat(live2d): 添加 Live2D 渲染插件及相关依赖,替代 Firefox kiosk 方案 2026-07-07 16:13:40 +08:00
2aac62b2bb feat(live2d): implement OpenGL ES 2.0 rendering backend with EGL support
- Add `gl.rs` for OpenGL ES 2.0 and EGL context management.
- Introduce `mod.rs` to organize Live2D plugin modules.
- Create `model.rs` for loading and managing Cubism models, including parameter handling and drawable data extraction.
- Implement shader compilation and rendering context setup for Live2D models.
2026-07-07 16:04:49 +08:00
9637bdd323 fix(Live2D): 用 bash kill -9 \ 替代 pkill(pkill 从 Rust 执行不生效) 2026-07-07 14:50:48 +08:00
00b9200235 fix(service): 去掉 User=showen(user service 不需要,导致 216/GROUP 错误) 2026-07-07 14:33:05 +08:00
e5a311fe3a fix(Live2D): pkill 用绝对路径 + service 加 PATH 环境变量 2026-07-07 14:11:23 +08:00
25508b4c66 fix(Live2D): pkill -9 强制杀 Firefox 进程树(SIGTERM 不够) 2026-07-07 13:59:04 +08:00
d64b393145 fix(Live2D): pkill firefox-esr 杀整个进程树(含 contentproc 子进程) 2026-07-07 13:39:54 +08:00
61256e4acb fix(Live2D): Firefox kiosk 进程清理用 pkill 兜底
Firefox esr 会 fork 子进程,child.kill() 只杀父进程导致残留。
三处清理(ConfigReloaded live2d 分支/切回视频分支/stop)都加
pkill -f live2d-display.html 兜底。
2026-07-07 13:33:59 +08:00
77d788d7a5 feat(Live2D): 设备端 Firefox kiosk 全屏渲染 Live2D 角色
Cubism Core 闭源且无 aarch64 Linux native 库,无法纯 Rust 原生渲染。
设备有 X11+kwin+Firefox 140+OpenGL,用 Firefox kiosk 模式全屏渲染
Live2D 模型(复用 Web 端 Cubism SDK),是 aarch64 上唯一可行路径。

1. src/plugins/http/live2d_display.html — 设备端专用全屏 Live2D 页面
   - 纯 Canvas 渲染,无 UI 控件
   - 轮询 /api/live2d/talking 驱动嘴部动效
   - 自动从 /api/config 读取 live2d_model 路径

2. src/plugins/http/mod.rs — HttpState 加 live2d_talking AtomicBool
   - set_live2d_talking/getter 方法

3. src/plugins/http/routes.rs
   - 新增 GET /live2d-display.html 路由
   - 新增 GET /api/live2d/talking 路由(设备端轮询)
   - chat_text_route 对话开始时 set_live2d_talking(true)
   - 回复后按字数估算时长延迟 set_live2d_talking(false)

4. src/plugins/video/mod.rs — Live2D 模式管理 Firefox kiosk 进程
   - ConfigReloaded render_type=live2d:停止视频,启动 firefox --kiosk
   - ConfigReloaded render_type=video:杀掉 Firefox,恢复视频播放
   - stop() 清理 Firefox 子进程
2026-07-07 13:24:57 +08:00
af5b975375 fix(Web): 角色列表显示所有角色 + 区分视频/Live2D 模式
问题:切到 live2d 配置后角色列表只显示一个角色;角色卡片用当前配置的
character 信息渲染,而非各配置自己的。

1. routes.rs config_available_route 扩展返回结构
   - configs 从字符串数组改为对象数组,每项含 filename + character 元信息
     (name/character_type/render_type/live2d_model)
   - 读取每个配置文件提取 character 块

2. chat.js loadCharacters 用各配置自己的 character 元信息渲染卡片
   - 显示角色名/类型徽章/渲染模式(video/live2d)
   - Live2D 角色额外显示模型路径
   - 策略说明区分"视频播放+状态机"和"Canvas渲染+嘴部动效"

3. chat.js switchCharacter 改为 await loadConfig() 确保配置加载后再刷新
   loadAvailableConfigs 兼容新旧返回结构
2026-07-07 12:26:45 +08:00
53ed155299 fix(Live2D): 用完整 dog 配置结构生成 live2d_anime.json
之前手写的配置缺少 scenes/triggers/transition 等必需字段导致解析失败。
改为基于 dog_state_machine.json 复制并替换 character 块 + display 块,
保留完整的 playlist/scenes/state_machine 结构,仅修改角色相关字段。
设备验证:--validate 通过,配置切换成功,文字对话正常(小雅人设生效)。
2026-07-07 12:18:23 +08:00
297c2a86fe fix(Live2D): 修正 Haru 模型路径 + gitignore 排除 live2d 模型文件
- live2d_model: haru/index.model3.json → haru/Haru.model3.json(匹配实际文件名)
- .gitignore: 添加 configs/live2d/(模型文件 4.2M 不进 git,部署时单独下载)
- .gitignore: 添加 .commit_msg.txt
2026-07-07 12:00:18 +08:00
39fa2a135a feat(Live2D): 集成 Cubism Web SDK 实现二次元角色渲染
1. routes.rs 新增 GET /live2d/* 静态文件路由
   - 从 source_dir/live2d/ 托管模型文件(.moc3/.model3.json/.png/.physics3.json)
   - 路径穿越防护(禁止 .. 段,canonicalize 校验在 base 内)

2. chat.html 引入 Live2D 渲染依赖
   - PixiJS v6.5.10 + Cubism Core + pixi-live2d-display(CDN)
   - 支持 Cubism 2.1 和 4 全版本模型

3. chat.js 实现 Live2D 模型加载与渲染
   - applyCharStrategy live2d 分支:初始化 PIXI.Application + canvas
   - loadLive2DModel:从 /live2d/<model_path> 加载模型,自动缩放居中
   - 说话动效:对话期间 ParamMouthOpenY 正弦波开合(模拟说话)
   - 呼吸动效:ParamBreath 持续起伏
   - destroyLive2D:切角色时清理 PIXI 资源

4. configs/live2d_anime.json 新建二次元角色"小雅"
   - character_type: live2d, render_type: live2d
   - live2d_model: haru/index.model3.json(Cubism 官方免费样例)
   - persona: 温柔二次元少女人设
   - playlist 复用 dog 的视频作为占位(Live2D 模式下 Web 端显示 canvas)
2026-07-07 11:56:29 +08:00
1ab77df85d refactor(Web): 统一入口 — 合并旧控制台与 AI 对话页为单一 chat.html
- / 和 /index.html 和 /chat 都服务统一页面(原 / 服务旧 WEB_UI_HTML)
- 删除 routes.rs 内嵌的 WEB_UI_HTML 常量(424 行),HTML 拆为 chat.html + chat.js
- 整合 8 个 tab:对话 / 角色 / 模型 / 播放控制 / 视频管理 / 文件管理 / 网络设置 / 系统配置
- 保留全部旧功能:WS 实时状态、播放控制、触发器、视频上传/删除、文件管理器、WiFi 扫描/连接/热点、BLE、配置切换/编辑/显示设置、APK 下载弹窗
- 保留全部新功能:文字对话、语音录音(客户端 WAV 转码)、角色切换、模型管理、Live2D 占位
2026-07-07 11:29:53 +08:00
29 changed files with 3879 additions and 582 deletions

21
.analyze.sh Normal file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# 分析下载页面
echo "=== Page size ==="
wc -c /tmp/dl_page.html
echo "=== All URLs containing download/zip/native ==="
grep -oiE 'https?://[^"'\'' <>]+(download|\.zip|native|cubism)[^"'\'' <>]*' /tmp/dl_page.html | sort -u | head -40
echo "=== Script sources ==="
grep -oiE '<script[^>]*src="[^"]*"' /tmp/dl_page.html | head -20
echo "=== Data attributes ==="
grep -oiE 'data-[a-z]+="[^"]*"' /tmp/dl_page.html | head -20
echo "=== Form actions ==="
grep -oiE '<form[^>]*action="[^"]*"' /tmp/dl_page.html | head -10
echo "=== Any cubism.live2d URLs ==="
grep -oiE 'https?://[a-z.]*live2d[^"'\'' <>]*' /tmp/dl_page.html | sort -u | head -30
echo "=== DONE ==="

6
.build.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
cd /home/showen/Showen/ShowenV2
export PATH=/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH
cargo check 2>&1 | grep -E "^error" | head -60
echo "---SUMMARY---"
cargo check 2>&1 | tail -3

39
.dl_core.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
# 尝试下载 Cubism SDK for Native (含 Core 库)
cd /tmp
echo "=== Try downloading Cubism SDK for Native ==="
# Live2D 官方下载通常用这种 URL 模式
# 先尝试常见的下载链接
URLS=(
"https://cubism.live2d.com/sdk-native/bin/CubismSdkForNative.zip"
"https://www.live2d.com/download/cubism-sdk/download-native/?agree=true"
"https://download.live2d.com/cubism-sdk/native/CubismSdkForNative.zip"
)
for url in "${URLS[@]}"; do
echo "Trying: $url"
wget --timeout=15 --tries=1 -q "$url" -O test_download.bin 2>&1
if [ $? -eq 0 ]; then
SIZE=$(stat -c%s test_download.bin 2>/dev/null || echo 0)
echo " Downloaded: $SIZE bytes"
if [ "$SIZE" -gt 100000 ]; then
echo " Looks like a real file! Checking type..."
file test_download.bin
mv test_download.bin CubismSdkForNative.zip
echo " SUCCESS: saved as CubismSdkForNative.zip"
exit 0
fi
else
echo " Failed or too small"
fi
rm -f test_download.bin
done
echo "=== Direct URL attempts failed ==="
echo "Checking actual download page for hidden links..."
curl -sL "https://www.live2d.com/zh-CHS/sdk/download/native/" 2>/dev/null | grep -oiE 'href="[^"]*\.zip[^"]*"' | head -10
echo "---"
curl -sL "https://www.live2d.com/download/cubism-sdk/download-native/" 2>/dev/null | grep -oiE 'href="[^"]*download[^"]*"' | head -10
echo "=== DONE ==="

15
.extract.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
cd /tmp
echo "=== Extracting SDK ==="
unzip -o cubism_sdk_native.zip -d cubism_sdk 2>&1 | tail -5
echo "=== Finding Core .so files ==="
find cubism_sdk -name "libLive2DCubismCore*" -type f 2>/dev/null
echo "=== Checking arm64 linux .so ==="
ls -lh cubism_sdk/Core/dll/experimental/linux/arm64/ 2>/dev/null
echo "=== File type ==="
file cubism_sdk/Core/dll/experimental/linux/arm64/libLive2DCubismCore.so 2>/dev/null
echo "=== DONE ==="

16
.find_cargo.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
echo "=== find cargo config ==="
find /home/showen -name "config.toml" -path "*.cargo*" 2>/dev/null
find /home/showen -name "config" -path "*.cargo*" 2>/dev/null
find / -name "config.toml" -path "*cargo*" 2>/dev/null | head -5
echo "=== check .cargo dir ==="
ls -la /home/showen/.cargo/ 2>/dev/null
echo "=== rustup cargo home ==="
echo $CARGO_HOME
ls -la ~/.cargo/ 2>/dev/null
echo "=== project cargo ==="
ls -la /home/showen/Showen/ShowenV2/.cargo/ 2>/dev/null
find /home/showen/Showen -name ".cargo" -type d 2>/dev/null

5
.gitignore vendored
View File

@@ -3,10 +3,15 @@ target/
*.swp
*.swo
*~
extern/
# 模型文件不进 git只保留下载链接在 model_manager.rs 清单里
model_download/
model_store/
# Live2D 模型文件不进 git体积大部署时单独下载到设备
configs/live2d/
# 测试脚本
.test_*.sh
.chat_req.json
.commit_msg.txt

BIN
.haru.moc3 Normal file

Binary file not shown.

115
.haru.model3.json Normal file
View File

@@ -0,0 +1,115 @@
{
"Version": 3,
"FileReferences": {
"Moc": "Haru.moc3",
"Textures": [
"Haru.2048/texture_00.png",
"Haru.2048/texture_01.png"
],
"Physics": "Haru.physics3.json",
"Pose": "Haru.pose3.json",
"DisplayInfo": "Haru.cdi3.json",
"Expressions": [
{
"Name": "F01",
"File": "expressions/F01.exp3.json"
},
{
"Name": "F02",
"File": "expressions/F02.exp3.json"
},
{
"Name": "F03",
"File": "expressions/F03.exp3.json"
},
{
"Name": "F04",
"File": "expressions/F04.exp3.json"
},
{
"Name": "F05",
"File": "expressions/F05.exp3.json"
},
{
"Name": "F06",
"File": "expressions/F06.exp3.json"
},
{
"Name": "F07",
"File": "expressions/F07.exp3.json"
},
{
"Name": "F08",
"File": "expressions/F08.exp3.json"
}
],
"Motions": {
"Idle": [
{
"File": "motions/haru_g_idle.motion3.json",
"FadeInTime": 0.5,
"FadeOutTime": 0.5
},
{
"File": "motions/haru_g_m15.motion3.json",
"FadeInTime": 0.5,
"FadeOutTime": 0.5
}
],
"TapBody": [
{
"File": "motions/haru_g_m26.motion3.json",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Sound": "sounds/haru_talk_13.wav"
},
{
"File": "motions/haru_g_m06.motion3.json",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Sound": "sounds/haru_Info_14.wav"
},
{
"File": "motions/haru_g_m20.motion3.json",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Sound": "sounds/haru_normal_6.wav"
},
{
"File": "motions/haru_g_m09.motion3.json",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Sound": "sounds/haru_Info_04.wav"
}
]
},
"UserData": "Haru.userdata3.json"
},
"Groups": [
{
"Target": "Parameter",
"Name": "EyeBlink",
"Ids": [
"ParamEyeLOpen",
"ParamEyeROpen"
]
},
{
"Target": "Parameter",
"Name": "LipSync",
"Ids": [
"ParamMouthOpenY"
]
}
],
"HitAreas": [
{
"Id": "HitArea",
"Name": "Head"
},
{
"Id": "HitArea2",
"Name": "Body"
}
]
}

23
.ssh_check.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
echo "=== DOWNLOAD TOOLS ==="
which wget 2>/dev/null && echo "wget: yes" || echo "wget: no"
which curl 2>/dev/null && echo "curl: yes" || echo "curl: no"
echo "=== OPENGL/EGL LIBS ==="
ls /usr/lib/aarch64-linux-gnu/libEGL* 2>/dev/null || echo "no EGL lib"
ls /usr/lib/aarch64-linux-gnu/libGLESv2* 2>/dev/null || echo "no GLES lib"
ls /usr/lib/aarch64-linux-gnu/libGL* 2>/dev/null || echo "no GL lib"
echo "=== PKG CONFIG ==="
pkg-config --list-all 2>/dev/null | grep -iE 'egl|gles|gl' || echo "no pkg-config gl"
echo "=== BUILD TOOLS ==="
gcc --version 2>/dev/null | head -1 || echo "no gcc"
g++ --version 2>/dev/null | head -1 || echo "no g++"
cmake --version 2>/dev/null | head -1 || echo "no cmake"
echo "=== RUST ==="
ls ~/.rustup/toolchains/*/bin/rustc 2>/dev/null | head -1
~/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin/rustc --version 2>/dev/null
echo "=== DONE ==="

View File

@@ -37,3 +37,6 @@ ureq = "2"
flate2 = "1"
tar = "0.4"
semver = "1"
# Live2D 渲染
image = { version = "0.25", default-features = false, features = ["png"] }

1191
configs/live2d_anime.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,9 @@ Wants=network.target
[Service]
Type=simple
User=showen
Environment=DISPLAY=:0
Environment=XAUTHORITY=/home/showen/.Xauthority
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/sbin
WorkingDirectory=/home/showen/Showen/ShowenV2
ExecStart=/home/showen/Showen/ShowenV2/target/release/showen_v2 --config /home/showen/Showen/ShowenV2/configs/dog_state_machine.json
Restart=on-failure

View File

@@ -17,6 +17,7 @@ pub mod plugin_ids {
pub const BLE: &str = "ble";
pub const DEVICE: &str = "device";
pub const SCREEN: &str = "screen";
pub const LIVE2D: &str = "live2d";
}
#[cfg(test)]

View File

@@ -5,8 +5,8 @@ use showen_v2::core::service_manager::ServiceManager;
#[cfg(not(test))]
use showen_v2::core::version_manager::VersionManager;
use showen_v2::plugins::{
ai::AiPlugin, ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, screen::ScreenPlugin,
video::VideoPlugin, wifi::WifiPlugin,
ai::AiPlugin, ble::BlePlugin, device::DevicePlugin, http::HttpPlugin, live2d::Live2DPlugin,
screen::ScreenPlugin, video::VideoPlugin, wifi::WifiPlugin,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -75,6 +75,9 @@ fn main() -> Result<()> {
manager.register(Box::new(VideoPlugin::new()));
println!(" ✓ VideoPlugin");
manager.register(Box::new(Live2DPlugin::new()));
println!(" ✓ Live2DPlugin");
manager.register(Box::new(BlePlugin::new()));
println!(" ✓ BlePlugin");

View File

@@ -6,145 +6,47 @@
<title>Showen 数字生命控制台</title>
<style>
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px;--pet:#f59e0b;--human:#6366f1;--singer:#ec4899;--live2d:#22c55e}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.app{max-width:1000px;margin:0 auto;padding:16px 20px}
h1{font-size:20px;font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px}
h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green)}
*{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}::selection{background:var(--accent);color:#fff}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.app{max-width:1200px;margin:0 auto;padding:16px 20px}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px;flex-wrap:wrap}header h1{font-size:20px;font-weight:600;display:flex;align-items:center;gap:8px;background:linear-gradient(135deg,#6366f1,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}header h1 .dot{width:8px;height:8px;border-radius:50%;background:var(--green);-webkit-text-fill-color:initial}.header-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.header-link{display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border-radius:8px;color:#fff;font:inherit;cursor:pointer;text-decoration:none;background:var(--accent)}.header-link.dl{background:linear-gradient(135deg,var(--accent),#a78bfa)}.ws-badge{font-size:11px;padding:4px 10px;border-radius:99px;border:1px solid var(--border);color:var(--muted);background:var(--surface);white-space:nowrap}
.subtitle{font-size:12px;color:var(--muted);margin-bottom:16px}
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid var(--border)}
.tab{padding:8px 16px;cursor:pointer;border-radius:8px 8px 0 0;color:var(--muted);font-size:14px;transition:.2s;border-bottom:2px solid transparent;margin-bottom:-1px}
.tab:hover{color:var(--text)}
.tab.active{color:var(--text);border-bottom-color:var(--accent);background:var(--accent-glow)}
.panel{display:none}.panel.active{display:block}
.char-bar{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:12px}
.char-bar .avatar{width:40px;height:40px;border-radius:50%;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:20px}
.char-bar .info{flex:1}
.char-bar .info .name{font-weight:600;font-size:15px}
.char-bar .info .type-badge{font-size:11px;padding:2px 8px;border-radius:4px;margin-left:8px;text-transform:uppercase}
.type-badge.pet{background:rgba(245,158,11,.2);color:var(--pet)}
.type-badge.human{background:rgba(99,102,241,.2);color:var(--human)}
.type-badge.singer{background:rgba(236,72,153,.2);color:var(--singer)}
.type-badge.live2d{background:rgba(34,197,94,.2);color:var(--live2d)}
.char-bar .render-badge{font-size:11px;color:var(--muted);padding:2px 8px;border:1px solid var(--border);border-radius:4px}
.chat-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;overflow-y:auto;padding:16px;margin-bottom:12px}
.msg{margin-bottom:12px;max-width:80%}
.msg.user{margin-left:auto;text-align:right}
.msg .bubble{display:inline-block;padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.5}
.msg.user .bubble{background:var(--accent);color:#fff;border-bottom-right-radius:4px}
.msg.bot .bubble{background:var(--surface2);border:1px solid var(--border);border-bottom-left-radius:4px}
.msg .audio{margin-top:6px;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.msg .audio audio{height:28px;max-width:240px}
.msg .meta{font-size:11px;color:var(--muted);margin-top:4px}
.chat-input{display:flex;gap:8px}
.chat-input input{flex:1;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;outline:none}
.chat-input input:focus{border-color:var(--accent)}
.chat-input button{padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;white-space:nowrap}
.chat-input button:disabled{opacity:.5;cursor:not-allowed}
.status-bar{font-size:12px;color:var(--muted);margin-top:8px;min-height:18px}
.status-bar.error{color:var(--red)}
.status-bar.busy{color:var(--amber)}
.live2d-container{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;margin-bottom:12px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}
.live2d-placeholder{color:var(--muted);text-align:center}
.live2d-placeholder .icon{font-size:48px;margin-bottom:8px}
.live2d-talking{position:absolute;top:8px;right:8px;background:var(--green);color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
.char-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.char-card{background:var(--surface);border:2px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;transition:.2s}
.char-card:hover{border-color:var(--accent)}
.char-card.active{border-color:var(--green);background:var(--accent-glow)}
.char-card .name{font-size:16px;font-weight:600;margin-bottom:4px}
.char-card .strat{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}
.char-card .badge{display:inline-block;margin-top:8px;padding:2px 8px;background:var(--green);color:#fff;border-radius:4px;font-size:11px}
.model-bar{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap}
.model-stat{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 14px;font-size:12px}
.model-stat .label{color:var(--muted);margin-right:4px}
.model-stat .val{font-weight:600}
.model-list{display:flex;flex-direction:column;gap:8px}
.model-item{display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px}
.model-item .info{flex:1}
.model-item .info .name{font-weight:600;font-size:14px}
.model-item .info .desc{font-size:12px;color:var(--muted);margin-top:2px}
.model-item .actions{display:flex;gap:6px}
.model-item .actions button{padding:6px 12px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px}
.model-item .actions button:hover{border-color:var(--accent);color:var(--accent)}
.model-item .actions button.danger:hover{border-color:var(--red);color:var(--red)}
.model-item .actions button.active{background:var(--green);color:#fff;border-color:var(--green)}
.tag{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:6px}
.tag.llm{background:rgba(99,102,241,.2);color:#a5b4fc}
.tag.asr{background:rgba(34,197,94,.2);color:#86efac}
.tag.tts{background:rgba(245,158,11,.2);color:#fcd34d}
.tag.downloaded{background:rgba(34,197,94,.2);color:#86efac}
.tag.active{background:var(--green);color:#fff}
.mic-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--surface2);color:var(--text);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:.2s;flex-shrink:0}
.mic-btn:hover:not(:disabled){background:var(--accent);color:#fff}
.mic-btn.recording{background:var(--red);color:#fff;animation:micPulse 1s infinite}
.mic-btn:disabled{opacity:.4;cursor:not-allowed}
@keyframes micPulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.5)}50%{box-shadow:0 0 0 8px rgba(239,68,68,0)}}
.rec-tip{font-size:11px;color:var(--amber);margin-top:6px;line-height:1.5}
.rec-tip code{background:var(--surface2);padding:1px 5px;border-radius:3px;font-size:10px}
.audio-inline{margin-top:8px}
.audio-inline audio{width:100%;max-width:280px;height:32px}
.tabs{display:flex;gap:4px;background:var(--surface);border-radius:var(--radius);padding:4px;margin-bottom:16px;overflow-x:auto}.tab{padding:8px 16px;border-radius:8px;border:0;background:0;color:var(--muted);cursor:pointer;font:inherit;white-space:nowrap;transition:all .2s}.tab:hover{color:var(--text);background:var(--surface2)}.tab.active{color:#fff;background:var(--accent);box-shadow:0 2px 8px rgba(99,102,241,.3)}.panel{display:none}.panel.active{display:block}
.char-bar{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:12px}.char-bar .avatar{width:40px;height:40px;border-radius:50%;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:20px}.char-bar .info{flex:1}.char-bar .info .name{font-weight:600;font-size:15px}.char-bar .info .type-badge{font-size:11px;padding:2px 8px;border-radius:4px;margin-left:8px;text-transform:uppercase}.type-badge.pet{background:rgba(245,158,11,.2);color:var(--pet)}.type-badge.human{background:rgba(99,102,241,.2);color:var(--human)}.type-badge.singer{background:rgba(236,72,153,.2);color:var(--singer)}.type-badge.live2d{background:rgba(34,197,94,.2);color:var(--live2d)}.char-bar .render-badge{font-size:11px;color:var(--muted);padding:2px 8px;border:1px solid var(--border);border-radius:4px}
.chat-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;overflow-y:auto;padding:16px;margin-bottom:12px}.msg{margin-bottom:12px;max-width:80%}.msg.user{margin-left:auto;text-align:right}.msg .bubble{display:inline-block;padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.5}.msg.user .bubble{background:var(--accent);color:#fff;border-bottom-right-radius:4px}.msg.bot .bubble{background:var(--surface2);border:1px solid var(--border);border-bottom-left-radius:4px}.msg .meta{font-size:11px;color:var(--muted);margin-top:4px}.chat-input{display:flex;gap:8px}.chat-input input{flex:1;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px;outline:none}.chat-input input:focus{border-color:var(--accent)}.send-btn{padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;white-space:nowrap}button:disabled{opacity:.5;cursor:not-allowed}.status-bar{font-size:12px;color:var(--muted);margin-top:8px;min-height:18px}.status-bar.error{color:var(--red)}.status-bar.busy{color:var(--amber)}
.live2d-container{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);height:45vh;margin-bottom:12px;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden}.live2d-placeholder{color:var(--muted);text-align:center}.live2d-placeholder .icon{font-size:48px;margin-bottom:8px}.live2d-talking{position:absolute;top:8px;right:8px;background:var(--green);color:#fff;padding:4px 10px;border-radius:4px;font-size:12px;animation:pulse 1s infinite}@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
.mic-btn{width:44px;height:44px;border-radius:50%;border:none;background:var(--surface2);color:var(--text);cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;transition:.2s;flex-shrink:0}.mic-btn:hover:not(:disabled){background:var(--accent);color:#fff}.mic-btn.recording{background:var(--red);color:#fff;animation:micPulse 1s infinite}.mic-btn:disabled{opacity:.4;cursor:not-allowed}@keyframes micPulse{0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.5)}50%{box-shadow:0 0 0 8px rgba(239,68,68,0)}}.rec-tip{font-size:11px;color:var(--amber);margin-top:6px;line-height:1.5}.rec-tip code{background:var(--surface2);padding:1px 5px;border-radius:3px;font-size:10px}.audio-inline{margin-top:8px}.audio-inline audio{width:100%;max-width:280px;height:32px}
.char-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}.char-card{background:var(--surface);border:2px solid var(--border);border-radius:var(--radius);padding:16px;cursor:pointer;transition:.2s}.char-card:hover{border-color:var(--accent)}.char-card.active{border-color:var(--green);background:var(--accent-glow)}.char-card .name{font-size:16px;font-weight:600;margin-bottom:4px}.char-card .strat{font-size:11px;color:var(--muted);margin-top:6px;line-height:1.4}.char-card .badge{display:inline-block;margin-top:8px;padding:2px 8px;background:var(--green);color:#fff;border-radius:4px;font-size:11px}
.model-bar{display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap}.model-stat{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 14px;font-size:12px}.model-stat .label{color:var(--muted);margin-right:4px}.model-stat .val{font-weight:600}.model-list{display:flex;flex-direction:column;gap:8px}.model-item{display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:12px 16px}.model-item .info{flex:1}.model-item .info .name{font-weight:600;font-size:14px}.model-item .info .desc{font-size:12px;color:var(--muted);margin-top:2px}.model-item .actions{display:flex;gap:6px}.model-item .actions button{padding:6px 12px;border:1px solid var(--border);background:var(--surface2);color:var(--text);border-radius:6px;cursor:pointer;font-size:12px}.model-item .actions button:hover{border-color:var(--accent);color:var(--accent)}.model-item .actions button.danger:hover{border-color:var(--red);color:var(--red)}.model-item .actions button.active{background:var(--green);color:#fff;border-color:var(--green)}.tag{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;margin-left:6px}.tag.llm{background:rgba(99,102,241,.2);color:#a5b4fc}.tag.asr{background:rgba(34,197,94,.2);color:#86efac}.tag.tts{background:rgba(245,158,11,.2);color:#fcd34d}.tag.downloaded{background:rgba(34,197,94,.2);color:#86efac}.tag.active{background:var(--green);color:#fff}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px}.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}.card h2{font-size:15px;font-weight:600;margin-bottom:12px}.stats{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.stat{background:var(--surface2);border-radius:8px;padding:8px 12px}.stat .label{display:block;font-size:11px;color:var(--muted)}.stat .val{font-size:14px;font-weight:600}.stat .val.ok{color:var(--green)}.stat .val.warn{color:var(--amber)}.stat .val.err{color:var(--red)}.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}.btns>*{flex:1;min-width:80px}button{font:inherit;cursor:pointer;border:0;border-radius:8px;padding:8px 16px;transition:all .15s}.btn{background:var(--accent);color:#fff}.btn:hover{filter:brightness(1.1)}.btn-s{background:var(--surface2);color:var(--text);border:1px solid var(--border)}.btn-s:hover{border-color:var(--accent);color:var(--accent)}.btn-g{background:var(--green);color:#fff}.btn-d{background:rgba(239,68,68,.15);color:var(--red);border:1px solid rgba(239,68,68,.3)}.btn-d:hover{background:rgba(239,68,68,.25)}.row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap}.row>div{flex:1;min-width:120px}label{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}input,select,textarea{width:100%;padding:8px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;color:var(--text);font:inherit}input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none}textarea{min-height:200px;font-family:ui-monospace,"Cascadia Code","Consolas",monospace;font-size:12px;resize:vertical}.list{display:flex;flex-direction:column;gap:6px;max-height:260px;overflow-y:auto}.list .item{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--surface2);border-radius:8px}.list .item .name{flex:1;font-size:13px}.list .item .meta{font-size:11px;color:var(--muted);white-space:nowrap}.list .item .dir-icon{font-size:16px}.file-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px}.file-breadcrumb{font-size:12px;color:var(--muted);margin-bottom:8px}.file-breadcrumb span{cursor:pointer}.file-breadcrumb span:hover{text-decoration:underline}.file-breadcrumb .sep{margin:0 4px;cursor:default}.checkbox-label{display:flex;align-items:center;gap:4px;margin:0}
.toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--surface2);border:1px solid var(--border);color:var(--text);padding:10px 20px;border-radius:8px;z-index:999;display:none;box-shadow:0 4px 20px rgba(0,0,0,.4)}.toast.ok{border-color:rgba(34,197,94,.4)}.toast.err{border-color:rgba(239,68,68,.4);color:var(--red)}
.modal-backdrop{position:fixed;inset:0;background:rgba(10,12,18,.72);backdrop-filter:blur(10px);display:none;align-items:center;justify-content:center;padding:20px;z-index:998}.modal-backdrop.open{display:flex}.download-modal{width:min(100%,420px);background:linear-gradient(180deg,rgba(36,40,54,.98),rgba(26,29,39,.98));border:1px solid rgba(99,102,241,.28);border-radius:20px;padding:22px;box-shadow:0 24px 60px rgba(0,0,0,.4)}.download-modal h2{font-size:22px;margin-bottom:8px}.download-modal p{color:var(--muted);margin-bottom:18px}.download-qr{display:flex;justify-content:center;align-items:center;padding:14px;background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.2);border-radius:16px;margin-bottom:16px}.download-qr img{display:block;width:180px;height:180px;border-radius:12px;background:#fff}.download-actions{display:flex;gap:10px;flex-wrap:wrap}.download-actions>*{flex:1}.download-meta{font-size:13px;margin-bottom:14px;padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid var(--border)}.download-meta.warn{color:var(--amber);border-color:rgba(245,158,11,.3);background:rgba(245,158,11,.08)}.download-note{font-size:12px;color:var(--muted);margin-top:12px}
@media(max-width:600px){.app{padding:12px}.tabs{flex-wrap:wrap}.grid{grid-template-columns:1fr}.header-actions{width:100%}}
</style>
</head>
<body>
<div class="app">
<h1><span class="dot"></span> Showen 数字生命控制台</h1>
<div class="subtitle">统一入口 · 角色对话 · 模型管理</div>
<div class="char-bar" id="charBar">
<div class="avatar" id="charAvatar">🐶</div>
<div class="info"><span class="name" id="charName">加载中...</span><span class="type-badge pet" id="charType">pet</span></div>
<div class="render-badge" id="charRender">video</div>
</div>
<header><h1><span class="dot"></span> Showen 数字生命控制台</h1><div class="header-actions"><span class="ws-badge" id="ws-badge">WS 连接中</span><button class="header-link dl" onclick="openDownloadModal()">下载 App</button></div></header>
<div class="subtitle">统一入口 · 角色对话 · 模型管理 · 设备控制</div>
<div class="char-bar" id="charBar"><div class="avatar" id="charAvatar">🐶</div><div class="info"><span class="name" id="charName">加载中...</span><span class="type-badge pet" id="charType">pet</span></div><div class="render-badge" id="charRender">video</div></div>
<div class="tabs">
<div class="tab active" data-tab="chat">对话</div>
<div class="tab" data-tab="character">角色</div>
<div class="tab" data-tab="models">模型</div>
<button class="tab active" data-tab="chat">对话</button><button class="tab" data-tab="character">角色</button><button class="tab" data-tab="models">模型</button><button class="tab" data-tab="control">播放控制</button><button class="tab" data-tab="videos">视频管理</button><button class="tab" data-tab="files">文件管理</button><button class="tab" data-tab="wifi">网络设置</button><button class="tab" data-tab="settings">系统配置</button>
</div>
<div class="panel active" id="panel-chat">
<div id="renderArea"></div>
<div class="chat-box" id="chatBox" style="display:none"></div>
<div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button id="sendBtn" onclick="sendChat()">发送</button><button class="mic-btn" id="micBtn" title="按住说话(松开发送)" disabled>🎙</button></div>
<div class="status-bar" id="chatStatus"></div>
<div class="rec-tip" id="recTip"></div>
<div class="panel active" id="panel-chat"><div id="renderArea"></div><div class="chat-box" id="chatBox" style="display:none"></div><div class="chat-input"><input type="text" id="chatInput" placeholder="输入文字与角色对话..." autocomplete="off"><button class="send-btn" id="sendBtn" onclick="sendChat()">发送</button><button class="mic-btn" id="micBtn" title="按住说话(松开发送)" disabled>🎙</button></div><div class="status-bar" id="chatStatus"></div><div class="rec-tip" id="recTip"></div></div>
<div class="panel" id="panel-character"><div style="margin-bottom:12px;font-size:13px;color:var(--muted)">不同角色类型采用不同渲染和对话策略pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)</div><div class="char-grid" id="charGrid"><div style="color:var(--muted)">加载中...</div></div></div>
<div class="panel" id="panel-models"><div class="model-bar" id="modelBar"></div><div class="model-list" id="modelList"><div style="color:var(--muted)">加载中...</div></div></div>
<section class="panel" id="panel-control"><div class="grid"><div class="card"><h2>播放状态</h2><div class="stats"><div class="stat"><span class="label">状态</span><span id="st-state" class="val">--</span></div><div class="stat"><span class="label">当前视频</span><span id="st-video" class="val">--</span></div><div class="stat"><span class="label">索引</span><span id="st-index" class="val">--</span></div><div class="stat"><span class="label">列表长度</span><span id="st-len" class="val">--</span></div></div></div><div class="card"><h2>播放操作</h2><div class="btns"><button class="btn-s" onclick="wsReady?wsCmd({cmd:'prev'}):api('POST','/api/previous')">上一个</button><button class="btn" onclick="wsReady?wsCmd({cmd:'play'}):api('POST','/api/play')">播放</button><button class="btn-s" onclick="wsReady?wsCmd({cmd:'pause'}):api('POST','/api/pause')">暂停</button><button class="btn" onclick="wsReady?wsCmd({cmd:'next'}):api('POST','/api/next')">下一个</button></div><div class="row" style="margin-top:12px"><div><label>跳转到索引</label><input id="goto-idx" type="number" min="0" placeholder="0"></div><div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="gotoVideo()">跳转</button></div></div></div><div class="card" style="grid-column:1/-1"><h2>触发器</h2><div class="btns"><button class="btn-s" onclick="triggerPreset('voice','name')">语音唤醒</button><button class="btn-s" onclick="triggerPreset('button','button1')">按钮 1</button><button class="btn-s" onclick="triggerPreset('button','button2')">按钮 2</button><button class="btn-s" onclick="triggerPreset('sensor','touch')">触摸感应</button></div><div class="row" style="margin-top:10px"><div><label>触发器名称</label><input id="tr-name" type="text" placeholder="voice"></div><div><label>触发器值</label><input id="tr-value" type="text" placeholder="name"></div><div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="triggerCustom()">发送</button></div></div></div></div></section>
<section class="panel" id="panel-videos"><div class="grid"><div class="card"><h2>上传视频</h2><input id="upload-file" type="file" accept="video/*" multiple><div class="btns"><button class="btn" onclick="uploadVideos()">上传</button></div></div><div class="card"><h2>设备视频文件</h2><div id="video-list" class="list"><div class="item"><span class="name">加载中...</span></div></div><div class="btns"><button class="btn-s" onclick="loadVideoList()">刷新</button></div></div></div></section>
<section class="panel" id="panel-files"><div class="card"><h2>文件管理器</h2><div class="file-toolbar"><select id="fm-dir" onchange="fmNavigate('')"><option value="videos">视频目录</option><option value="configs">配置目录</option><option value="plugins">插件目录</option></select><button class="btn-s" onclick="fmRefresh()">刷新</button><button class="btn-s" onclick="fmMkdir()">新建文件夹</button><button class="btn" onclick="document.getElementById('fm-upload-input').click()">上传文件</button><input id="fm-upload-input" type="file" multiple style="display:none" onchange="fmUpload(this)"></div><div id="fm-breadcrumb" class="file-breadcrumb"></div><div id="fm-list" class="list" style="max-height:400px"><div class="item"><span class="name">选择目录后加载...</span></div></div><div class="btns" style="margin-top:8px"><button class="btn-d" id="fm-del-btn" onclick="fmDeleteSelected()" style="display:none">删除选中</button></div></div></section>
<section class="panel" id="panel-wifi"><div class="grid"><div class="card"><h2>网络状态</h2><div class="stats"><div class="stat"><span class="label">WiFi</span><span id="wifi-connected" class="val">--</span></div><div class="stat"><span class="label">SSID</span><span id="wifi-ssid" class="val">--</span></div><div class="stat"><span class="label">IP</span><span id="wifi-ip" class="val">--</span></div><div class="stat"><span class="label">BLE</span><span id="ble-status" class="val">--</span></div></div><div class="btns"><button class="btn-s" onclick="loadWifiStatus();loadBleStatus()">刷新状态</button></div></div><div class="card"><h2>WiFi 扫描</h2><div id="wifi-list" class="list"><div class="item"><span class="name">点击扫描搜索附近网络</span></div></div><div class="btns"><button class="btn" onclick="scanWifi()">扫描网络</button></div></div><div class="card"><h2>连接 WiFi</h2><label>SSID</label><input id="wifi-ssid-input" type="text" placeholder="网络名称"><label style="margin-top:8px">密码</label><input id="wifi-pass-input" type="password" placeholder="WiFi 密码"><div class="btns"><button class="btn" onclick="connectWifi()">连接</button></div></div><div class="card"><h2>热点 / BLE</h2><label>热点名称</label><input id="ap-ssid" type="text" value="Showen"><label style="margin-top:8px">热点密码</label><input id="ap-pass" type="text" value="12345678"><div class="btns"><button class="btn-g" onclick="startAP()">开启热点</button><button class="btn-d" onclick="stopAP()">关闭热点</button></div><label style="margin-top:8px">BLE 设备名</label><input id="ble-name" type="text" value="Showen"><div class="btns"><button class="btn-s" onclick="startBLE()">启动 BLE</button><button class="btn-d" onclick="stopBLE()">停止 BLE</button></div></div></div></section>
<section class="panel" id="panel-settings"><div class="grid"><div class="card" style="grid-column:1/-1"><h2>配置文件切换</h2><div class="row"><div><label>当前配置</label><select id="cfg-select"><option>加载中...</option></select></div><div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="switchConfig()">切换</button></div></div></div><div class="card"><h2>显示设置</h2><div id="display-form"></div><div class="btns"><button class="btn" onclick="saveDisplay()">保存显示设置</button></div></div><div class="card"><h2>配置编辑器</h2><textarea id="cfg-editor" placeholder="加载中..."></textarea><div class="btns"><button class="btn-s" onclick="loadConfig()">重新加载</button><button class="btn-s" onclick="formatConfig()">格式化</button><button class="btn" onclick="saveConfig()">保存配置</button></div></div></div></section>
</div>
<div class="panel" id="panel-character">
<div style="margin-bottom:12px;font-size:13px;color:var(--muted)">不同角色类型采用不同渲染和对话策略pet(视频+拟声) / human(视频+自然) / singer(视频+元气) / live2d(Canvas渲染+动效)</div>
<div class="char-grid" id="charGrid"><div style="color:var(--muted)">加载中...</div></div>
</div>
<div class="panel" id="panel-models">
<div class="model-bar" id="modelBar"></div>
<div class="model-list" id="modelList"><div style="color:var(--muted)">加载中...</div></div>
</div>
</div>
<script>
const $=s=>document.querySelector(s);const sessionId='web_'+Date.now();let chatBusy=false;let currentChar={name:'未知',character_type:'pet',render_type:'video',talk_state:'talk'};
const charStrategies={pet:{icon:'🐶',label:'宠物',render:'video',placeholder:'和宠物说说话...'},human:{icon:'🧑',label:'数字人',render:'video',placeholder:'和数字人对话...'},singer:{icon:'🎤',label:'歌姬',render:'video',placeholder:'和歌姬聊天...'},live2d:{icon:'✨',label:'Live2D数字人',render:'live2d',placeholder:'和Live2D数字人对话...'}};
document.querySelectorAll('.tab').forEach(t=>{t.onclick=()=>{document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.panel').forEach(x=>x.classList.remove('active'));t.classList.add('active');$('#panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='character')loadCharacters();if(t.dataset.tab==='models')loadModels();};});
function applyCharStrategy(char){currentChar=char;const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('#charAvatar').textContent=strat.icon;$('#charName').textContent=char.name||'未知角色';const tb=$('#charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('#charRender').textContent=char.render_type||strat.render;const ra=$('#renderArea');const cb=$('#chatBox');if((char.render_type||strat.render)==='live2d'){cb.style.display='none';ra.innerHTML='<div class="live2d-container" id="live2dContainer"><div class="live2d-placeholder"><div class="icon">✨</div>Live2D 渲染区<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>';}else{ra.innerHTML='';cb.style.display='block';}$('#chatInput').placeholder=strat.placeholder;}
async function sendChat(){const input=$('#chatInput');const text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;$('#chatStatus').textContent='思考中...';$('#chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&&currentChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,session_id:sessionId})});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;$('#chatInput').focus();}
function audioUrl(path){return '/api/chat/audio/file?path='+encodeURIComponent(path);}
function handleChatResponse(data,botMsg){if(data.error){showChatError(botMsg,'错误: '+data.error);return;}botMsg.querySelector('.bubble').textContent=data.reply_text||(data.transcription?'(无回复)':'(空回复)');if(data.transcription&&data.transcription.trim()){const tr=document.createElement('div');tr.className='meta';tr.style.color='var(--muted)';tr.textContent='🎤 你说: '+data.transcription;botMsg.querySelector('.bubble').insertAdjacentElement('beforebegin',tr);}if(data.reply_audio_path){const ad=document.createElement('div');ad.className='audio-inline';ad.innerHTML='<span style="font-size:12px;color:var(--muted)">🔊 回复语音</span><audio controls src="'+audioUrl(data.reply_audio_path)+'"></audio>';botMsg.appendChild(ad);}$('#chatStatus').textContent='';$('#chatStatus').className='status-bar';const rl=(data.reply_text||'').length;const tm=Math.max(2000,rl*200);setTimeout(()=>{triggerTalkState(false);showLive2dTalking(false);},tm);}
function showChatError(botMsg,msg){botMsg.querySelector('.bubble').textContent=msg;botMsg.querySelector('.bubble').style.color='var(--red)';$('#chatStatus').textContent=msg;$('#chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false);}
function triggerTalkState(talking){const scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene})}).catch(()=>{});}
function showLive2dTalking(talking){const c=$('#live2dContainer');if(!c)return;const ex=c.querySelector('.live2d-talking');if(talking&&!ex){const b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b);}else if(!talking&&ex)ex.remove();}
function addMsg(role,text){const box=$('#chatBox');const div=document.createElement('div');div.className='msg '+role;const t=new Date().toLocaleTimeString('zh-CN',{hour12:false});div.innerHTML='<div class="bubble"></div><div class="meta">'+t+'</div>';div.querySelector('.bubble').textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div;}
$('#chatInput').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat();}});
async function loadCharacters(){try{const res=await fetch('/api/config/available');const data=await res.json();const grid=$('#charGrid');grid.innerHTML='';const cfgRes=await fetch('/api/config');const cfg=await cfgRes.json();const char=cfg.character||{};const strat=charStrategies[char.character_type||'pet']||charStrategies.pet;(data.configs||[]).forEach(c=>{const isActive=c===(data.active||'');const card=document.createElement('div');card.className='char-card'+(isActive?' active':'');const name=char.name||c.replace(/\.json$/,'').replace(/_/g,' ');card.innerHTML='<div class="name">'+(strat.icon||'📦')+' '+name+'</div><div><span class="type-badge '+(char.character_type||'pet')+'">'+strat.label+'</span> '+(char.render_type||strat.render)+'</div><div class="strat">策略: 拟声/自然/元气 (依据角色)</div>'+(isActive?'<span class="badge">使用中</span>':'');card.onclick=()=>switchCharacter(c);grid.appendChild(card);});}catch(e){$('#charGrid').innerHTML='<div style="color:var(--red)">加载失败</div>';}}
async function switchCharacter(filename){if(!confirm('切换到 '+filename+' ?'))return;try{const res=await fetch('/api/config/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename})});const data=await res.json();if(data.status==='ok'||(data.message&&data.message.includes('成功'))){alert('已切换');loadCurrentChar();loadCharacters();}else alert('失败: '+(data.message||'未知'));}catch(e){alert('失败: '+e.message);}}
async function loadCurrentChar(){try{const res=await fetch('/api/config');const cfg=await res.json();applyCharStrategy(cfg.character||{name:'未知',character_type:'pet',render_type:'video'});}catch(e){console.error(e);}}
async function loadModels(){try{const res=await fetch('/api/models');const data=await res.json();const bar=$('#modelBar');const u=Math.round((data.used_space||0)/1048576);const q=Math.round((data.quota||0)/1048576);bar.innerHTML='<div class="model-stat"><span class="label">配额:</span><span class="val">'+u+'/'+q+' MB</span></div><div class="model-stat"><span class="label">已下载:</span><span class="val">'+(data.models||[]).filter(m=>m.downloaded).length+'/'+(data.models||[]).length+'</span></div>';const list=$('#modelList');list.innerHTML='';const active=data.active||{};(data.models||[]).forEach(m=>{const item=document.createElement('div');item.className='model-item';const ia=active[m.kind]===m.id;const s=Math.round(m.size/1048576);const me=Math.round(m.memory_required/1048576);let a='';if(m.downloaded){if(ia)a='<button class="active" disabled>使用中</button>';else a='<button onclick="switchModel(\''+m.id+'\',\''+m.kind+'\')">切换</button> <button class="danger" onclick="deleteModel(\''+m.id+'\')">删除</button>';}else a='<button onclick="downloadModel(\''+m.id+'\')">下载</button>';item.innerHTML='<div class="info"><div class="name">'+m.name+' <span class="tag '+m.kind+'">'+m.kind.toUpperCase()+'</span>'+(m.downloaded?'<span class="tag downloaded">已下载</span>':'')+(ia?'<span class="tag active">活跃</span>':'')+'</div><div class="desc">'+s+'MB | 需 '+me+'MB | '+m.recommended_tier+'</div></div><div class="actions">'+a+'</div>';list.appendChild(item);});}catch(e){$('#modelList').innerHTML='<div style="color:var(--red)">失败</div>';}}
async function downloadModel(id){try{const res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'下载已启动');setTimeout(loadModels,3000);}catch(e){alert('失败');}}
async function switchModel(id,kind){try{const res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind})});const data=await res.json();alert(data.message||'已切换');loadModels();}catch(e){alert('失败');}}
async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{const res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});const data=await res.json();alert(data.message||'已删除');loadModels();}catch(e){alert('失败');}}
initMic();
async function initMic(){const micBtn=$('#micBtn');const tip=$('#recTip');if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){tip.innerHTML='当前浏览器不支持录音。请使用文字对话。';return;}if(!window.isSecureContext){micBtn.disabled=true;tip.innerHTML='⚠️ 录音需要安全上下文HTTPS 或 localhost。当前为 HTTP 访问,录音已禁用。<br>启用方法(桌面 Chrome/Edge右键浏览器图标 → 属性 → 在目标末尾加 <code>--unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000</code> → 重启浏览器并访问本页。<br>或直接用文字对话,回复语音仍可正常播放。';return;}let mediaRecorder=null;let chunks=[];let recStream=null;let recTimer=null;const startRec=async()=>{if(chatBusy)return;try{recStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:16000,echoCancellation:true,noiseSuppression:true}});chunks=[];mediaRecorder=new MediaRecorder(recStream);mediaRecorder.ondataavailable=e=>{if(e.data.size>0)chunks.push(e.data);};mediaRecorder.onstop=()=>{if(recTimer){clearTimeout(recTimer);recTimer=null;}recStream.getTracks().forEach(t=>t.stop());recStream=null;const blob=new Blob(chunks,{type:mediaRecorder.mimeType||'audio/webm'});if(blob.size<1000){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='录音过短,已取消';$('#chatStatus').className='status-bar error';return;}sendAudioBlob(blob,mediaRecorder.mimeType||'audio/webm');};mediaRecorder.start();micBtn.classList.add('recording');micBtn.textContent='⏹';$('#chatStatus').textContent='录音中...(松开发送)';$('#chatStatus').className='status-bar busy';recTimer=setTimeout(()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){stopRec();$('#chatStatus').textContent='录音达到 30s 上限,自动发送';}},30000);}catch(e){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('#chatStatus').textContent='麦克风启动失败: '+e.message;$('#chatStatus').className='status-bar error';}};const stopRec=()=>{if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙';}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',e=>{e.preventDefault();startRec();});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',e=>{e.preventDefault();stopRec();});}
async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('#sendBtn').disabled=true;const micBtn=$('#micBtn');micBtn.disabled=true;$('#chatStatus').textContent='转码中...';$('#chatStatus').className='status-bar busy';const userMsg=addMsg('user','🎤 [语音消息]');const playUrl=URL.createObjectURL(blob);const up=document.createElement('div');up.className='audio-inline';up.innerHTML='<span style="font-size:11px;color:var(--muted)">你的录音</span><audio controls src="'+playUrl+'"></audio>';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&&currentChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);const botMsg=addMsg('bot','思考中...');try{const wavBlob=await blobToWav(blob);$('#chatStatus').textContent='识别中...';const res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});const data=await res.json();handleChatResponse(data,botMsg);}catch(e){showChatError(botMsg,'请求失败: '+e.message);}chatBusy=false;$('#sendBtn').disabled=false;micBtn.disabled=false;}
async function blobToWav(blob){const arrayBuf=await blob.arrayBuffer();const audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});const decoded=await audioCtx.decodeAudioData(arrayBuf);const pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000);}
function decodeToMonoPCM16(audioBuffer,targetRate){const numCh=audioBuffer.numberOfChannels;const len=audioBuffer.length;const srcRate=audioBuffer.sampleRate;const outLen=Math.round(len*targetRate/srcRate);const out=new Int16Array(outLen);const tmp=new Float32Array(outLen);for(let ch=0;ch<numCh;ch++){const data=audioBuffer.getChannelData(ch);for(let i=0;i<outLen;i++){const srcIdx=i*srcRate/targetRate;const i0=Math.floor(srcIdx);const i1=Math.min(i0+1,len-1);const frac=srcIdx-i0;tmp[i]+=data[i0]*(1-frac)+data[i1]*frac;}}for(let i=0;i<outLen;i++){let s=tmp[i]/numCh;s=Math.max(-1,Math.min(1,s));out[i]=s<0?s*0x8000:s*0x7FFF;}return out;}
function encodeWavBlob(samples,sampleRate){const buf=new ArrayBuffer(44+samples.length*2);const view=new DataView(buf);const writeStr=(off,s)=>{for(let i=0;i<s.length;i++)view.setUint8(off+i,s.charCodeAt(i));};writeStr(0,'RIFF');view.setUint32(4,36+samples.length*2,true);writeStr(8,'WAVE');writeStr(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);view.setUint32(24,sampleRate,true);view.setUint32(28,sampleRate*2,true);view.setUint16(32,2,true);view.setUint16(34,16,true);writeStr(36,'data');view.setUint32(40,samples.length*2,true);let off=44;for(let i=0;i<samples.length;i++){view.setInt16(off,samples[i],true);off+=2;}return new Blob([buf],{type:'audio/wav'});}
loadCurrentChar();
</script>
<div class="toast" id="toast"></div>
<div class="modal-backdrop" id="download-modal" onclick="closeDownloadModal(event)"><div class="download-modal"><h2>下载手机 App</h2><p>扫描二维码或点击下载,支持蓝牙配网和远程控制</p><div class="download-qr"><img id="download-qr-image" style="display:none"></div><div class="download-meta" id="download-meta">正在获取 App 信息...</div><div class="download-actions"><a class="header-link dl" id="download-apk-link" style="display:none;text-decoration:none" href="#">下载 APK</a><button class="btn-s" onclick="closeDownloadModal()">关闭</button></div><div class="download-note">仅支持 Android 设备</div></div></div>
<!-- Live2D 渲染依赖PixiJS v6 + Cubism Core + pixi-live2d-display -->
<script src="https://cdn.jsdelivr.net/npm/pixi.js@6.5.10/dist/browser/pixi.min.js"></script>
<script src="https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/dylanNew/live2d/webgl/Live2D/lib/live2d.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pixi-live2d-display/dist/index.min.js"></script>
<script src="chat.js"></script>
</body>
</html>

79
src/plugins/http/chat.js Normal file
View File

@@ -0,0 +1,79 @@
/* Showen 统一控制台 JS — chat.js */
var $=function(id){return typeof id==='string'?document.getElementById(id):id};
var ws=null,wsReady=false,cachedConfig=null,fmCurrentDir='videos',fmCurrentPath='',chatBusy=false,currentChar={name:'未知',character_type:'pet',render_type:'video',talk_state:'talk'},sessionId='web_'+Date.now();
var charStrategies={pet:{icon:'🐶',label:'宠物',render:'video',placeholder:'和宠物说说话...'},human:{icon:'🧑',label:'数字人',render:'video',placeholder:'和数字人对话...'},singer:{icon:'🎤',label:'歌姬',render:'video',placeholder:'和歌姬聊天...'},live2d:{icon:'✨',label:'Live2D数字人',render:'live2d',placeholder:'和Live2D数字人对话...'}};
function toast(m,e){var el=$('toast');el.textContent=m;el.className='toast '+(e?'err':'ok');el.style.display='block';clearTimeout(el._t);el._t=setTimeout(function(){el.style.display='none'},3000)}
function esc(v){return String(v).replace(/[&<>"]/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]})}
function ea(v){return esc(v).replace(/'/g,'&#39;')}
function js(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
function formatBytes(s){if(s===undefined||s===null||isNaN(s))return'--';if(s<1024)return s+' B';if(s<1048576)return(s/1024).toFixed(1)+' KB';if(s<1073741824)return(s/1048576).toFixed(1)+' MB';return(s/1073741824).toFixed(1)+' GB'}
document.querySelectorAll('.tab').forEach(function(t){t.onclick=function(){document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(e){e.classList.remove('active')});t.classList.add('active');$('panel-'+t.dataset.tab).classList.add('active');var tab=t.dataset.tab;if(tab==='character')loadCharacters();if(tab==='models')loadModels();if(tab==='videos')loadVideoList();if(tab==='files')fmRefresh();if(tab==='wifi'){loadWifiStatus();loadBleStatus()}if(tab==='settings'&&!cachedConfig){loadConfig();loadAvailableConfigs()}}});
function connectWS(){var proto=location.protocol==='https:'?'wss:':'ws:';ws=new WebSocket(proto+'//'+location.host+'/ws');ws.onopen=function(){wsReady=true;$('ws-badge').textContent='WS 已连接';$('ws-badge').style.borderColor='rgba(34,197,94,.4)';$('ws-badge').style.color='#22c55e';$('ws-badge').style.background='rgba(34,197,94,.1)'};ws.onclose=function(){wsReady=false;$('ws-badge').textContent='WS 断开';$('ws-badge').style.borderColor='rgba(239,68,68,.3)';$('ws-badge').style.color='#ef4444';$('ws-badge').style.background='rgba(239,68,68,.1)';setTimeout(connectWS,2000)};ws.onerror=function(){wsReady=false};ws.onmessage=function(ev){try{var msg=JSON.parse(ev.data);if(msg.type==='status_update')applyStatus(msg.data);else if(msg.type==='state_update')toast('场景: '+msg.data.old_state+' -> '+msg.data.new_state);else if(msg.type==='wifi_update')applyWifi(msg.data);else if(msg.type==='ble_update')applyBle(msg.data);else if(msg.type==='config_update'){cachedConfig=msg.data;applyCharStrategy(msg.data.character||{})}}catch(e){}}}
function wsCmd(obj){if(wsReady)ws.send(JSON.stringify(obj));else toast('WebSocket 未连接',true)}
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}
function applyStatus(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val warn'}else if(d.paused){el.textContent='已暂停';el.className='val warn'}else{el.textContent='播放中';el.className='val ok'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(applyStatus).catch(function(){})}
function applyWifi(d){if(d.connected!==undefined){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val ok':'val err';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}}
function applyBle(d){if(d.ready!==undefined){var el=$('ble-status');el.textContent=d.ready?'运行中':'未就绪';el.className=d.ready?'val ok':'val warn'}}
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}if(wsReady)wsCmd({cmd:'goto',index:parseInt(idx,10)});else api('POST','/api/goto/'+idx)}
function triggerPreset(n,v){if(wsReady)wsCmd({cmd:'trigger',name:n,value:v||''});else api('POST','/api/trigger/'+encodeURIComponent(n)+'/'+encodeURIComponent(v||''))}
function triggerCustom(){var n=$('tr-name').value,v=$('tr-value').value;if(!n){toast('请输入触发器名',true);return}triggerPreset(n,v)}
function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item"><span class="name">目录中没有视频文件</span></div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span class="name">'+esc(f.name)+'</span><span class="meta">'+sz+'</span><button class="btn-d" onclick="deleteVideo(\''+js(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})}
function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}
function fmRefresh(){fmNavigate(fmCurrentPath)}
function fmNavigate(subpath){fmCurrentDir=$('fm-dir').value;fmCurrentPath=subpath||'';var url='/api/files/'+fmCurrentDir;if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);var bc=$('fm-breadcrumb');var parts=fmCurrentPath?fmCurrentPath.split('/'):[];var html='<span onclick="fmNavigate(\'\')">'+({'videos':'视频目录','configs':'配置目录','plugins':'插件目录'}[fmCurrentDir]||fmCurrentDir)+'</span>';var acc='';for(var i=0;i<parts.length;i++){if(parts[i]){acc+=(acc?'/':'')+parts[i];html+='<span class="sep">/</span><span onclick="fmNavigate(\''+js(acc)+'\')">'+esc(parts[i])+'</span>'}}bc.innerHTML=html;fetch(url).then(function(r){return r.json()}).then(function(entries){var el=$('fm-list');if(entries.status==='error'){el.innerHTML='<div class="item"><span class="name">'+esc(entries.message)+'</span></div>';return}if(!entries.length){el.innerHTML='<div class="item"><span class="name">空目录</span></div>';$('fm-del-btn').style.display='none';return}var html='';for(var i=0;i<entries.length;i++){var e=entries[i];var sz=e.is_dir?'':e.size<1048576?(e.size/1024).toFixed(1)+' KB':(e.size/1048576).toFixed(1)+' MB';var fp=fmCurrentPath?(fmCurrentPath+'/'+e.name):e.name;html+='<div class="item"><label class="checkbox-label"><input type="checkbox" class="fm-check" data-path="'+esc(fp)+'" data-isdir="'+(e.is_dir?'1':'0')+'"></label>';if(e.is_dir){html+='<span class="dir-icon">&#128193;</span><span class="name" style="cursor:pointer;color:var(--amber)" onclick="fmNavigate(\''+js(fp)+'\')">'+esc(e.name)+'</span>'}else{html+='<span class="name">'+esc(e.name)+'</span>'}html+='<span class="meta">'+sz+'</span>';if(!e.is_dir){html+='<button class="btn-s" onclick="fmDownload(\''+js(fp)+'\')">下载</button>'}html+='</div>'}el.innerHTML=html;el.querySelectorAll('.fm-check').forEach(function(cb){cb.onchange=fmUpdateDelBtn});fmUpdateDelBtn()}).catch(function(){toast('加载文件列表失败',true)})}
function fmUpdateDelBtn(){var cbs=document.querySelectorAll('.fm-check:checked');$('fm-del-btn').style.display=cbs.length?'':'none';$('fm-del-btn').textContent='删除选中 ('+cbs.length+')'}
function fmDeleteSelected(){var cbs=document.querySelectorAll('.fm-check:checked');if(!cbs.length)return;if(!confirm('确定删除 '+cbs.length+' 个项目?'))return;var promises=[];cbs.forEach(function(cb){var p=cb.dataset.path;promises.push(fetch('/api/files/'+fmCurrentDir+'/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})}).then(function(r){return r.json()}))});Promise.all(promises).then(function(results){var ok=0,fail=0;results.forEach(function(r){if(r.status==='ok')ok++;else fail++});toast('删除完成: '+ok+' 成功'+(fail?' / '+fail+' 失败':''),fail>0);fmRefresh()})}
function fmDownload(path){window.open('/api/files/'+fmCurrentDir+'/download?path='+encodeURIComponent(path),'_blank')}
function fmUpload(input){if(!input.files.length)return;var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);var url='/api/files/'+fmCurrentDir+'/upload';if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);fetch(url,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';fmRefresh()}).catch(function(){toast('上传失败',true)})}
function fmMkdir(){var name=prompt('输入新文件夹名称:');if(!name)return;var path=fmCurrentPath?(fmCurrentPath+'/'+name):name;fetch('/api/files/'+fmCurrentDir+'/mkdir',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');fmRefresh()}).catch(function(){toast('创建失败',true)})}
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(applyWifi).catch(function(){})}
function scanWifi(){$('wifi-list').innerHTML='<div class="item"><span class="name">扫描中...</span></div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item"><span class="name">未发现 WiFi</span></div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span class="name">'+esc(n.ssid||'隐藏网络')+'</span><span class="meta">'+esc(String(n.signal||0))+' / '+esc(n.security||'OPEN')+'</span><button class="btn-s" onclick="selectWifi(\''+js(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})}
function selectWifi(ssid){$('wifi-ssid-input').value=ssid}
function connectWifi(){var s=$('wifi-ssid-input').value,p=$('wifi-pass-input').value;if(!s){toast('请输入 WiFi 名称',true);return}if(wsReady)wsCmd({cmd:'connect',ssid:s,password:p});else api('POST','/api/wifi/connect',{ssid:s,password:p}).then(function(){setTimeout(loadWifiStatus,1500)})}
function startAP(){var s=$('ap-ssid').value||'showen',p=$('ap-pass').value||'12345678';if(p.length<8){toast('热点密码至少 8 位',true);return}if(wsReady)wsCmd({cmd:'ap_start',ssid:s,password:p});else api('POST','/api/wifi/ap/start',{ssid:s,password:p})}
function stopAP(){if(wsReady)wsCmd({cmd:'ap_stop'});else api('POST','/api/wifi/ap/stop')}
function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val ok'}else{el.textContent='未就绪';el.className='val warn'}}).catch(function(){})}
function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)}
function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)}
function loadAvailableConfigs(){fetch('/api/config/available').then(function(r){return r.json()}).then(function(d){var sel=$('cfg-select');sel.innerHTML='';(d.configs||[]).forEach(function(item){var f=typeof item==='string'?item:item.filename;var opt=document.createElement('option');opt.value=f;opt.textContent=f;if(f===d.active)opt.selected=true;sel.appendChild(opt)})}).catch(function(){})}
function switchConfig(){var f=$('cfg-select').value;if(!f){toast('请选择配置',true);return}if(!confirm('确定切换到 '+f+' ?'))return;api('POST','/api/config/switch',{filename:f}).then(function(){loadConfig();loadAvailableConfigs()})}
function loadConfig(){fetch('/api/config').then(function(r){return r.json()}).then(function(cfg){cachedConfig=cfg;$('cfg-editor').value=JSON.stringify(cfg,null,2);renderDisplay(cfg.display);applyCharStrategy(cfg.character||{})}).catch(function(){toast('加载配置失败',true)})}
function formatConfig(){try{$('cfg-editor').value=JSON.stringify(JSON.parse($('cfg-editor').value),null,2)}catch(_){toast('JSON 格式错误',true)}}
function saveConfig(){var raw=$('cfg-editor').value;try{JSON.parse(raw)}catch(_){toast('JSON 格式错误',true);return}api('POST','/api/config',raw).then(loadConfig)}
function renderDisplay(d){if(!d)return;var h='';h+='<label class="checkbox-label"><input id="d-fullscreen" type="checkbox" '+(d.fullscreen?'checked':'')+'> 全屏模式</label>';h+='<label>窗口标题</label><input id="d-title" type="text" value="'+ea(d.window_title||'')+'">';h+='<label>旋转角度</label><input id="d-rotation" type="number" value="'+ea(String(d.rotation||0))+'">';h+='<label>渲染宽度</label><input id="d-render-width" type="number" value="'+ea(String(d.render_width||1024))+'">';h+='<label>渲染高度</label><input id="d-render-height" type="number" value="'+ea(String(d.render_height||1024))+'">';h+='<label>色键下限 (H,S,V)</label><input id="d-ck-min" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_min?d.chroma_key.hsv_min.join(','):'0,0,200'))+'">';h+='<label>色键上限 (H,S,V)</label><input id="d-ck-max" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_max?d.chroma_key.hsv_max.join(','):'180,30,255'))+'">';h+='<label>透视校正点 (JSON)</label><input id="d-points" type="text" value="'+ea(JSON.stringify((d.perspective_correction&&d.perspective_correction.points)||[]))+'">';$('display-form').innerHTML=h}
function saveDisplay(){if(!cachedConfig){loadConfig();return}var n=JSON.parse(JSON.stringify(cachedConfig));n.display.fullscreen=$('d-fullscreen').checked;n.display.window_title=$('d-title').value;n.display.rotation=parseInt($('d-rotation').value||'0',10);n.display.render_width=parseInt($('d-render-width').value||'1024',10);n.display.render_height=parseInt($('d-render-height').value||'1024',10);n.display.chroma_key=n.display.chroma_key||{};n.display.chroma_key.hsv_min=$('d-ck-min').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.chroma_key.hsv_max=$('d-ck-max').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.perspective_correction=n.display.perspective_correction||{};try{n.display.perspective_correction.points=JSON.parse($('d-points').value)}catch(_){toast('透视点 JSON 无效',true);return}cachedConfig=n;$('cfg-editor').value=JSON.stringify(n,null,2);saveConfig()}
function updateDownloadModal(info){var meta=$('download-meta'),link=$('download-apk-link'),qr=$('download-qr-image');if(!info||!info.apk_available){meta.textContent='APK 尚未发布,请稍后再试';meta.className='download-meta warn';link.style.display='none';qr.style.display='none';qr.removeAttribute('src');return}var url=location.origin+(info.download_url||'/download/showen-app.apk');meta.textContent='版本 '+(info.version||'0.1.0')+' · '+formatBytes(info.apk_size);meta.className='download-meta';link.style.display='inline-flex';link.href=info.download_url||'/download/showen-app.apk';qr.style.display='block';qr.src='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(url)}
function openDownloadModal(){var modal=$('download-modal');$('download-meta').textContent='正在获取 App 信息...';$('download-meta').className='download-meta';$('download-apk-link').style.display='none';$('download-qr-image').style.display='none';$('download-qr-image').removeAttribute('src');modal.classList.add('open');fetch('/api/app/info').then(function(r){if(!r.ok)throw new Error('加载 App 信息失败');return r.json()}).then(updateDownloadModal).catch(function(){$('download-meta').textContent='APK 信息加载失败,请稍后再试';$('download-meta').className='download-meta warn';$('download-apk-link').style.display='none';$('download-qr-image').style.display='none'})}
function closeDownloadModal(ev){if(ev&&ev.target!==$('download-modal'))return;$('download-modal').classList.remove('open')}
document.addEventListener('keydown',function(ev){if(ev.key==='Escape')closeDownloadModal()});
function applyCharStrategy(char){currentChar=char;var strat=charStrategies[char.character_type||'pet']||charStrategies.pet;$('charAvatar').textContent=strat.icon;$('charName').textContent=char.name||'未知角色';var tb=$('charType');tb.textContent=strat.label;tb.className='type-badge '+(char.character_type||'pet');$('charRender').textContent=char.render_type||strat.render;var ra=$('renderArea');var cb=$('chatBox');if((char.render_type||strat.render)==='live2d'){cb.style.display='none';ra.innerHTML='<div class="live2d-container" id="live2dContainer"><canvas id="live2dCanvas"></canvas><div class="live2d-placeholder" id="live2dPlaceholder"><div class="icon">✨</div>加载 Live2D 模型中...<br><small>模型: '+(char.live2d_model||'未配置')+'</small></div></div>';initLive2D(char)}else{ra.innerHTML='';cb.style.display='block';destroyLive2D()}$('chatInput').placeholder=strat.placeholder}
async function sendChat(){var input=$('chatInput');var text=input.value.trim();if(!text||chatBusy)return;chatBusy=true;$('sendBtn').disabled=true;$('chatStatus').textContent='思考中...';$('chatStatus').className='status-bar busy';input.value='';addMsg('user',text);if(currentChar.render_type!=='live2d'&&currentChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);var botMsg=addMsg('bot','思考中...');try{var res=await fetch('/api/chat/text',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:text,session_id:sessionId})});var data=await res.json();handleChatResponse(data,botMsg)}catch(e){showChatError(botMsg,'请求失败: '+e.message)}chatBusy=false;$('sendBtn').disabled=false;$('chatInput').focus()}
function audioUrl(path){return '/api/chat/audio/file?path='+encodeURIComponent(path)}
function handleChatResponse(data,botMsg){if(data.error){showChatError(botMsg,'错误: '+data.error);return}botMsg.querySelector('.bubble').textContent=data.reply_text||(data.transcription?'(无回复)':'(空回复)');if(data.transcription&&data.transcription.trim()){var tr=document.createElement('div');tr.className='meta';tr.style.color='var(--muted)';tr.textContent='🎤 你说: '+data.transcription;botMsg.querySelector('.bubble').insertAdjacentElement('beforebegin',tr)}if(data.reply_audio_path){var ad=document.createElement('div');ad.className='audio-inline';ad.innerHTML='<span style="font-size:12px;color:var(--muted)">🔊 回复语音</span><audio controls src="'+audioUrl(data.reply_audio_path)+'"></audio>';botMsg.appendChild(ad)}$('chatStatus').textContent='';$('chatStatus').className='status-bar';var rl=(data.reply_text||'').length;var tm=Math.max(2000,rl*200);setTimeout(function(){triggerTalkState(false);showLive2dTalking(false)},tm)}
function showChatError(botMsg,msg){botMsg.querySelector('.bubble').textContent=msg;botMsg.querySelector('.bubble').style.color='var(--red)';$('chatStatus').textContent=msg;$('chatStatus').className='status-bar error';triggerTalkState(false);showLive2dTalking(false)}
function triggerTalkState(talking){var scene=talking?(currentChar.talk_state||'talk'):'idle';fetch('/api/scene',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scene:scene})}).catch(function(){})}
function showLive2dTalking(talking){live2dTalking=talking;if(!talking&&live2dModel&&live2dModel.internalModel&&live2dModel.internalModel.coreModel){live2dModel.internalModel.coreModel.setParameterValueById('ParamMouthOpenY',0)}}
function addMsg(role,text){var box=$('chatBox');var div=document.createElement('div');div.className='msg '+role;var t=new Date().toLocaleTimeString('zh-CN',{hour12:false});div.innerHTML='<div class="bubble"></div><div class="meta">'+t+'</div>';div.querySelector('.bubble').textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div}
$('chatInput').addEventListener('keydown',function(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendChat()}});
async function loadCharacters(){try{var res=await fetch('/api/config/available');var data=await res.json();var grid=$('charGrid');grid.innerHTML='';(data.configs||[]).forEach(function(item){var filename=item.filename||item;var char=item.character||{};var ct=char.character_type||'pet';var strat=charStrategies[ct]||charStrategies.pet;var isActive=filename===(data.active||'');var name=char.name||filename.replace(/\.json$/,'').replace(/_/g,' ');var rt=char.render_type||strat.render;var modelInfo=char.live2d_model?'<br><small style="color:var(--muted)">模型: '+char.live2d_model+'</small>':'';var card=document.createElement('div');card.className='char-card'+(isActive?' active':'');card.innerHTML='<div class="name">'+(strat.icon||'📦')+' '+name+'</div><div><span class="type-badge '+ct+'">'+strat.label+'</span> <span class="render-badge">'+rt+'</span></div><div class="strat">'+(rt==='live2d'?'Canvas 渲染 + 嘴部动效':'视频播放 + 状态机')+modelInfo+'</div>'+(isActive?'<span class="badge">使用中</span>':'');card.onclick=function(){switchCharacter(filename)};grid.appendChild(card)})}catch(e){$('charGrid').innerHTML='<div style="color:var(--red)">加载失败</div>'}}
async function switchCharacter(filename){if(!confirm('切换到 '+filename+' ?'))return;try{var res=await fetch('/api/config/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({filename:filename})});var data=await res.json();if(data.status==='ok'||(data.message&&data.message.includes('成功'))){toast('已切换');await loadConfig();loadCharacters()}else toast('失败: '+(data.message||'未知'),true)}catch(e){toast('失败: '+e.message,true)}}
async function loadModels(){try{var res=await fetch('/api/models');var data=await res.json();var bar=$('modelBar');var u=Math.round((data.used_space||0)/1048576);var q=Math.round((data.quota||0)/1048576);bar.innerHTML='<div class="model-stat"><span class="label">配额:</span><span class="val">'+u+'/'+q+' MB</span></div><div class="model-stat"><span class="label">已下载:</span><span class="val">'+(data.models||[]).filter(function(m){return m.downloaded}).length+'/'+(data.models||[]).length+'</span></div>';var list=$('modelList');list.innerHTML='';var active=data.active||{};(data.models||[]).forEach(function(m){var item=document.createElement('div');item.className='model-item';var ia=active[m.kind]===m.id;var s=Math.round(m.size/1048576);var me=Math.round(m.memory_required/1048576);var a='';if(m.downloaded){if(ia)a='<button class="active" disabled>使用中</button>';else a='<button onclick="switchModel(\''+m.id+'\',\''+m.kind+'\')">切换</button> <button class="danger" onclick="deleteModel(\''+m.id+'\')">删除</button>'}else a='<button onclick="downloadModel(\''+m.id+'\')">下载</button>';item.innerHTML='<div class="info"><div class="name">'+m.name+' <span class="tag '+m.kind+'">'+m.kind.toUpperCase()+'</span>'+(m.downloaded?'<span class="tag downloaded">已下载</span>':'')+(ia?'<span class="tag active">活跃</span>':'')+'</div><div class="desc">'+s+'MB | 需 '+me+'MB | '+m.recommended_tier+'</div></div><div class="actions">'+a+'</div>';list.appendChild(item)})}catch(e){$('modelList').innerHTML='<div style="color:var(--red)">失败</div>'}}
async function downloadModel(id){try{var res=await fetch('/api/models/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});var data=await res.json();toast(data.message||'下载已启动');setTimeout(loadModels,3000)}catch(e){toast('失败',true)}}
async function switchModel(id,kind){try{var res=await fetch('/api/models/switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id,kind:kind})});var data=await res.json();toast(data.message||'已切换');loadModels()}catch(e){toast('失败',true)}}
async function deleteModel(id){if(!confirm('删除 '+id+'?'))return;try{var res=await fetch('/api/models/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model_id:id})});var data=await res.json();toast(data.message||'已删除');loadModels()}catch(e){toast('失败',true)}}
initMic();
async function initMic(){var micBtn=$('micBtn');var tip=$('recTip');if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){tip.innerHTML='当前浏览器不支持录音。请使用文字对话。';return}if(!window.isSecureContext){micBtn.disabled=true;tip.innerHTML='⚠️ 录音需要安全上下文HTTPS 或 localhost。当前为 HTTP 访问,录音已禁用。<br>启用方法(桌面 Chrome/Edge右键浏览器图标 → 属性 → 在目标末尾加 <code>--unsafely-treat-insecure-origin-as-secure=http://192.168.31.105:5000</code> → 重启浏览器并访问本页。<br>或直接用文字对话,回复语音仍可正常播放。';return}var mediaRecorder=null,chunks=[],recStream=null,recTimer=null;var startRec=async function(){if(chatBusy)return;try{recStream=await navigator.mediaDevices.getUserMedia({audio:{channelCount:1,sampleRate:16000,echoCancellation:true,noiseSuppression:true}});chunks=[];mediaRecorder=new MediaRecorder(recStream);mediaRecorder.ondataavailable=function(e){if(e.data.size>0)chunks.push(e.data)};mediaRecorder.onstop=function(){if(recTimer){clearTimeout(recTimer);recTimer=null}recStream.getTracks().forEach(function(t){t.stop()});recStream=null;var blob=new Blob(chunks,{type:mediaRecorder.mimeType||'audio/webm'});if(blob.size<1000){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('chatStatus').textContent='录音过短,已取消';$('chatStatus').className='status-bar error';return}sendAudioBlob(blob,mediaRecorder.mimeType||'audio/webm')};mediaRecorder.start();micBtn.classList.add('recording');micBtn.textContent='⏹';$('chatStatus').textContent='录音中...(松开发送)';$('chatStatus').className='status-bar busy';recTimer=setTimeout(function(){if(mediaRecorder&&mediaRecorder.state==='recording'){stopRec();$('chatStatus').textContent='录音达到 30s 上限,自动发送'}},30000)}catch(e){micBtn.classList.remove('recording');micBtn.textContent='🎙';$('chatStatus').textContent='麦克风启动失败: '+e.message;$('chatStatus').className='status-bar error'}};var stopRec=function(){if(mediaRecorder&&mediaRecorder.state==='recording'){mediaRecorder.stop();micBtn.classList.remove('recording');micBtn.textContent='🎙'}};micBtn.disabled=false;micBtn.addEventListener('mousedown',startRec);micBtn.addEventListener('touchstart',function(e){e.preventDefault();startRec()});micBtn.addEventListener('mouseup',stopRec);micBtn.addEventListener('mouseleave',stopRec);micBtn.addEventListener('touchend',function(e){e.preventDefault();stopRec()})}
async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('sendBtn').disabled=true;var micBtn=$('micBtn');micBtn.disabled=true;$('chatStatus').textContent='转码中...';$('chatStatus').className='status-bar busy';var userMsg=addMsg('user','🎤 [语音消息]');var playUrl=URL.createObjectURL(blob);var up=document.createElement('div');up.className='audio-inline';up.innerHTML='<span style="font-size:11px;color:var(--muted)">你的录音</span><audio controls src="'+playUrl+'"></audio>';userMsg.appendChild(up);if(currentChar.render_type!=='live2d'&&currentChar.talk_state)triggerTalkState(true);if(currentChar.render_type==='live2d')showLive2dTalking(true);var botMsg=addMsg('bot','思考中...');try{var wavBlob=await blobToWav(blob);$('chatStatus').textContent='识别中...';var res=await fetch('/api/chat/audio',{method:'POST',headers:{'Content-Type':'audio/wav','X-Audio-Format':'wav'},body:wavBlob});var data=await res.json();handleChatResponse(data,botMsg)}catch(e){showChatError(botMsg,'请求失败: '+e.message)}chatBusy=false;$('sendBtn').disabled=false;micBtn.disabled=false}
/* WAV 转码:浏览器录音(webm/opus) → 16k mono PCM16 wavwhisper-cli 直接可处理 */
async function blobToWav(blob){var arrayBuf=await blob.arrayBuffer();var audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:16000});var decoded=await audioCtx.decodeAudioData(arrayBuf);var pcm=decodeToMonoPCM16(decoded,16000);audioCtx.close();return encodeWavBlob(pcm,16000)}
function decodeToMonoPCM16(audioBuffer,targetRate){var numCh=audioBuffer.numberOfChannels;var len=audioBuffer.length;var srcRate=audioBuffer.sampleRate;var outLen=Math.round(len*targetRate/srcRate);var out=new Int16Array(outLen);var tmp=new Float32Array(outLen);for(var ch=0;ch<numCh;ch++){var data=audioBuffer.getChannelData(ch);for(var i=0;i<outLen;i++){var srcIdx=i*srcRate/targetRate;var i0=Math.floor(srcIdx);var i1=Math.min(i0+1,len-1);var frac=srcIdx-i0;tmp[i]+=data[i0]*(1-frac)+data[i1]*frac}}for(var i=0;i<outLen;i++){var s=tmp[i]/numCh;s=Math.max(-1,Math.min(1,s));out[i]=s<0?s*0x8000:s*0x7FFF}return out}
function encodeWavBlob(samples,sampleRate){var buf=new ArrayBuffer(44+samples.length*2);var view=new DataView(buf);var writeStr=function(off,s){for(var i=0;i<s.length;i++)view.setUint8(off+i,s.charCodeAt(i))};writeStr(0,'RIFF');view.setUint32(4,36+samples.length*2,true);writeStr(8,'WAVE');writeStr(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,1,true);view.setUint16(22,1,true);view.setUint32(24,sampleRate,true);view.setUint32(28,sampleRate*2,true);view.setUint16(32,2,true);view.setUint16(34,16,true);writeStr(36,'data');view.setUint32(40,samples.length*2,true);var off=44;for(var i=0;i<samples.length;i++){view.setInt16(off,samples[i],true);off+=2}return new Blob([buf],{type:'audio/wav'})}
/* ==================== Live2D 渲染 ==================== */
var live2dApp=null,live2dModel=null,live2dTalking=false,live2dTickerFn=null;
function initLive2D(char){destroyLive2D();if(typeof PIXI==='undefined'||typeof PIXI.live2d==='undefined'){$('live2dPlaceholder').innerHTML='<div class="icon">⚠️</div>Live2D SDK 加载失败<br><small>请检查网络连接CDN 需联网)</small>';return}if(!char.live2d_model){$('live2dPlaceholder').innerHTML='<div class="icon">✨</div>未配置 Live2D 模型';return}try{var canvas=$('live2dCanvas');var container=$('live2dContainer');var w=container.clientWidth||400;var h=container.clientHeight||300;live2dApp=new PIXI.Application({view:canvas,backgroundAlpha:0,width:w,height:h});window.PIXI=PIXI;loadLive2DModel(char.live2d_model)}catch(e){$('live2dPlaceholder').innerHTML='<div class="icon">⚠️</div>初始化失败: '+e.message}}
async function loadLive2DModel(modelPath){var modelUrl='/live2d/'+modelPath.replace(/^\//,'').split('/').map(encodeURIComponent).join('/');var ph=$('live2dPlaceholder');try{var resolvedUrl=modelUrl;if(!resolvedUrl.endsWith('.model3.json')&&!resolvedUrl.endsWith('.model.json')){resolvedUrl=resolvedUrl.replace(/\/$/,'')+'/index.model3.json'}live2dModel=await PIXI.live2d.Live2DModel.from(resolvedUrl);live2dApp.stage.addChild(live2dModel);live2dModel.anchor.set(0.5,0.5);live2dModel.x=live2dApp.screen.width/2;live2dModel.y=live2dApp.screen.height/2;var scale=Math.min(live2dApp.screen.width/live2dModel.width,live2dApp.screen.height/live2dModel.height)*0.9;live2dModel.scale.set(scale);ph.style.display='none';live2dTickerFn=function(){if(live2dModel&&live2dModel.internalModel&&live2dModel.internalModel.coreModel){if(live2dTalking){var v=Math.abs(Math.sin(Date.now()/120))*0.8+0.2;live2dModel.internalModel.coreModel.setParameterValueById('ParamMouthOpenY',v)}else{live2dModel.internalModel.coreModel.setParameterValueById('ParamMouthOpenY',0)}var breath=Math.sin(Date.now()/2000)*0.05;live2dModel.internalModel.coreModel.setParameterValueById('ParamBreath',breath)}};live2dApp.ticker.add(live2dTickerFn)}catch(e){ph.innerHTML='<div class="icon">⚠️</div>模型加载失败: '+e.message+'<br><small>路径: '+modelUrl+'</small>';ph.style.display='block'}}
function destroyLive2D(){if(live2dTickerFn&&live2dApp){live2dApp.ticker.remove(live2dTickerFn)}live2dTickerFn=null;if(live2dModel){try{live2dModel.destroy()}catch(e){}live2dModel=null}if(live2dApp){try{live2dApp.destroy(true)}catch(e){}live2dApp=null}}
/* 启动 */
connectWS();refreshStatus();loadConfig();

View File

@@ -0,0 +1,72 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Live2D Display</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;background:#000;overflow:hidden}
#canvas{display:block;width:100%;height:100%}
#status{position:fixed;top:8px;left:8px;color:#888;font:12px monospace;z-index:10}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="status">加载中...</div>
<script src="https://cdn.jsdelivr.net/npm/pixi.js@6.5.10/dist/browser/pixi.min.js"></script>
<script src="https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/dylanNew/live2d/webgl/Live2D/lib/live2d.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pixi-live2d-display/dist/index.min.js"></script>
<script>
window.PIXI=PIXI;
var app,model,talking=false;
var statusEl=document.getElementById('status');
var canvas=document.getElementById('canvas');
function setStatus(s){statusEl.textContent=s}
async function init(){
try{
var res=await fetch('/api/config');
var cfg=await res.json();
var char=cfg.character||{};
var modelPath=char.live2d_model;
if(!modelPath){setStatus('未配置 live2d_model');return}
var w=window.innerWidth,h=window.innerHeight;
app=new PIXI.Application({view:canvas,backgroundAlpha:0,width:w,height:h,resolution:window.devicePixelRatio||1,autoDensity:true});
var modelUrl='/live2d/'+modelPath.replace(/^\//,'').split('/').map(encodeURIComponent).join('/');
setStatus('加载模型: '+modelPath);
model=await PIXI.live2d.Live2DModel.from(modelUrl);
app.stage.addChild(model);
model.anchor.set(0.5,0.5);
model.x=app.screen.width/2;
model.y=app.screen.height/2;
var scale=Math.min(app.screen.width/model.width,app.screen.height/model.height)*0.9;
model.scale.set(scale);
setStatus('');
app.ticker.add(function(){
if(!model||!model.internalModel||!model.internalModel.coreModel)return;
if(talking){var v=Math.abs(Math.sin(Date.now()/120))*0.8+0.2;model.internalModel.coreModel.setParameterValueById('ParamMouthOpenY',v)}
else{model.internalModel.coreModel.setParameterValueById('ParamMouthOpenY',0)}
var breath=Math.sin(Date.now()/2000)*0.05;
model.internalModel.coreModel.setParameterValueById('ParamBreath',breath);
});
// 轮询对话状态
setInterval(pollTalking,500);
}catch(e){setStatus('错误: '+e.message)}
}
var lastTalk=false;
async function pollTalking(){
try{
var res=await fetch('/api/live2d/talking');
if(res.ok){var d=await res.json();talking=!!d.talking}
}catch(e){}
}
window.addEventListener('resize',function(){
if(!app)return;
var w=window.innerWidth,h=window.innerHeight;
app.renderer.resize(w,h);
if(model){model.x=app.screen.width/2;model.y=app.screen.height/2;var s=Math.min(app.screen.width/model.width,app.screen.height/model.height)*0.9;model.scale.set(s)}
});
init();
</script>
</body>
</html>

View File

@@ -52,6 +52,8 @@ pub(crate) struct HttpState {
ai_pipeline: Mutex<Option<std::sync::Arc<crate::plugins::ai::ChatPipeline>>>,
/// AI 模型管理器共享句柄HTTP 模型管理 API 调用)
ai_models: Mutex<Option<std::sync::Arc<std::sync::Mutex<crate::plugins::ai::model_manager::ModelManager>>>>,
/// Live2D 说话状态(设备端 Firefox 轮询,驱动嘴部动效)
live2d_talking: AtomicBool,
}
impl HttpState {
@@ -81,6 +83,7 @@ impl HttpState {
plugin_states: Mutex::new(Vec::new()),
ai_pipeline: Mutex::new(None),
ai_models: Mutex::new(None),
live2d_talking: AtomicBool::new(false),
}
}
@@ -113,6 +116,16 @@ impl HttpState {
self.ai_models.lock().ok().and_then(|slot| slot.clone())
}
/// 设置 Live2D 说话状态(对话开始/结束时调用)
pub(crate) fn set_live2d_talking(&self, talking: bool) {
self.live2d_talking.store(talking, Ordering::SeqCst);
}
/// 获取 Live2D 说话状态(设备端轮询接口调用)
pub(crate) fn live2d_talking(&self) -> bool {
self.live2d_talking.load(Ordering::SeqCst)
}
fn publish_wifi_result(&self, payload: String) {
if let Ok(mut state) = self.wifi_response.lock() {
state.version += 1;

View File

@@ -149,6 +149,7 @@ pub(crate) fn build_routes(
let ai_api = chat_text_route(Arc::clone(&state))
.or(chat_audio_route(Arc::clone(&state)))
.or(chat_audio_file_route(Arc::clone(&state)))
.or(live2d_talking_route(Arc::clone(&state)))
.or(models_list_route(Arc::clone(&state)))
.or(model_download_route(Arc::clone(&state)))
.or(model_switch_route(Arc::clone(&state)))
@@ -158,8 +159,8 @@ pub(crate) fn build_routes(
let api = core_api.or(media_api).or(plugin_api).or(file_api).or(ai_api);
root_route()
.or(chat_page_route())
.or(download_route(Arc::clone(&state)))
.or(live2d_static_route(Arc::clone(&state)))
.or(ws_route(tx.clone(), Arc::clone(&state)))
.or(api)
.with(
@@ -170,24 +171,43 @@ pub(crate) fn build_routes(
)
}
/// GET / 和 /index.html 和 /chat — 统一入口chat.html
fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path::end()
let index = warp::path::end()
.and(warp::get())
.map(|| warp::reply::html(WEB_UI_HTML))
.or(
warp::path("index.html")
.map(|| warp::reply::html(include_str!("chat.html")));
let index_html = warp::path("index.html")
.and(warp::path::end())
.and(warp::get())
.map(|| warp::reply::html(WEB_UI_HTML)),
.map(|| warp::reply::html(include_str!("chat.html")));
let chat = warp::path("chat")
.and(warp::path::end())
.and(warp::get())
.map(|| warp::reply::html(include_str!("chat.html")));
let chat_js = warp::path("chat.js")
.and(warp::path::end())
.and(warp::get())
.map(|| {
let body = include_str!("chat.js");
match warp::http::Response::builder()
.header("Content-Type", "application/javascript; charset=utf-8")
.header("Cache-Control", "no-store")
.header("Content-Length", body.len().to_string())
.body(body.to_string())
{
Ok(resp) => resp.into_response(),
Err(_) => warp::reply::with_status(
warp::reply::html("JS 加载失败".to_string()),
StatusCode::INTERNAL_SERVER_ERROR,
)
}
/// GET /chat — AI 对话控制端页面
fn chat_page_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path("chat")
.into_response(),
}
});
let live2d_display = warp::path("live2d-display.html")
.and(warp::path::end())
.and(warp::get())
.map(|| warp::reply::html(include_str!("chat.html")))
.map(|| warp::reply::html(include_str!("live2d_display.html")));
index.or(index_html).or(chat).or(chat_js).or(live2d_display)
}
fn ws_route(
@@ -234,6 +254,97 @@ fn download_route(
})
}
/// GET /live2d/** — Live2D 模型静态文件(.moc3/.model3.json/.png/.physics3.json 等)
///
/// 从 source_dir/live2d/ 目录托管,支持子目录。用于浏览器 Cubism SDK 加载模型。
fn live2d_static_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path("live2d")
.and(warp::path::peek())
.and(warp::get())
.and(with_state(state))
.and_then(|tail: warp::path::Peek, state: Arc<HttpState>| async move {
let rel = tail.as_str();
// 防路径穿越:不允许 .. 段
if rel.contains("..") || rel.starts_with('/') {
return Ok::<_, Infallible>(
warp::reply::with_status(
warp::reply::html("无效路径".to_string()),
StatusCode::BAD_REQUEST,
)
.into_response(),
);
}
let base = state.config().source_dir.join("live2d");
let full = base.join(rel);
// canonicalize 校验最终路径仍在 base 内
let canonical_base = match base.canonicalize() {
Ok(p) => p,
Err(_) => {
return Ok(warp::reply::with_status(
warp::reply::html("live2d 目录不存在".to_string()),
StatusCode::NOT_FOUND,
)
.into_response());
}
};
let canonical_full = match full.canonicalize() {
Ok(p) => p,
Err(_) => {
return Ok(warp::reply::with_status(
warp::reply::html("文件不存在".to_string()),
StatusCode::NOT_FOUND,
)
.into_response());
}
};
if !canonical_full.starts_with(&canonical_base) || !canonical_full.is_file() {
return Ok(warp::reply::with_status(
warp::reply::html("文件不存在".to_string()),
StatusCode::NOT_FOUND,
)
.into_response());
}
// 按扩展名设 Content-Type
let ext = canonical_full
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let content_type = match ext.as_str() {
"json" => "application/json; charset=utf-8",
"png" => "image/png",
"moc3" => "application/octet-stream",
"physics3" => "application/json",
"cdi3" => "application/json",
"mot3" => "application/json",
_ => "application/octet-stream",
};
match std::fs::read(&canonical_full) {
Ok(data) => match warp::http::Response::builder()
.header("Content-Type", content_type)
.header("Content-Length", data.len().to_string())
.header("Cache-Control", "no-store")
.body(data)
{
Ok(resp) => Ok(resp.into_response()),
Err(_) => Ok(warp::reply::with_status(
warp::reply::html("响应构建失败".to_string()),
StatusCode::INTERNAL_SERVER_ERROR,
)
.into_response()),
},
Err(_) => Ok(warp::reply::with_status(
warp::reply::html("读取失败".to_string()),
StatusCode::INTERNAL_SERVER_ERROR,
)
.into_response()),
}
})
}
fn status_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
@@ -436,23 +547,57 @@ fn config_available_route(
.and_then(|state: Arc<HttpState>| async move {
let config = state.config();
let configs_dir = &config.source_dir;
let mut files: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(configs_dir) {
for entry in entries.flatten() {
// 每个配置文件提取 character 元信息,供前端渲染角色卡片
let mut entries: Vec<serde_json::Value> = Vec::new();
if let Ok(read_entries) = std::fs::read_dir(configs_dir) {
for entry in read_entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(".json") {
files.push(name);
if !name.ends_with(".json") {
continue;
}
let path = entry.path();
let char_info = match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(v) => {
let c = v.get("character").cloned().unwrap_or_default();
serde_json::json!({
"name": c.get("name").and_then(|n| n.as_str()).unwrap_or(&name).to_string(),
"character_type": c.get("character_type").and_then(|t| t.as_str()).unwrap_or("pet").to_string(),
"render_type": c.get("render_type").and_then(|r| r.as_str()).unwrap_or("video").to_string(),
"live2d_model": c.get("live2d_model").and_then(|m| m.as_str()).map(|s| s.to_string()),
})
}
Err(_) => serde_json::json!({
"name": name.clone(),
"character_type": "pet",
"render_type": "video",
"live2d_model": null,
}),
}
}
Err(_) => serde_json::json!({
"name": name.clone(),
"character_type": "pet",
"render_type": "video",
"live2d_model": null,
}),
};
entries.push(serde_json::json!({
"filename": name,
"character": char_info,
}));
}
files.sort();
// 标记当前活跃的配置
}
entries.sort_by(|a, b| {
a["filename"].as_str().cmp(&b["filename"].as_str())
});
let active = config.source_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let result = serde_json::json!({
"configs": files,
"configs": entries,
"active": active,
});
Ok::<_, Infallible>(json_response(StatusCode::OK, &result))
@@ -2015,6 +2160,9 @@ fn chat_text_route(
};
// AI 管线是同步阻塞调用ASR/LLM/TTS 子进程),用 spawn_blocking 避免阻塞 tokio reactor
// 设置 Live2D 说话状态(设备端 Firefox 轮询驱动嘴部动效)
let talking_state = Arc::clone(&state);
talking_state.set_live2d_talking(true);
let resp = tokio::task::spawn_blocking(move || pipeline.run(&chat_req))
.await
.unwrap_or_else(|e| ChatResponse {
@@ -2024,6 +2172,21 @@ fn chat_text_route(
reply_audio_path: None,
error: Some(format!("AI 管线执行失败: {e}")),
});
// 回复音频时长估算后重置 talking按字数估算
let reset_after = if resp.error.is_none() {
std::time::Duration::from_millis(
((resp.reply_text.len() as u64) * 200).max(2000),
)
} else {
std::time::Duration::from_millis(0)
};
let reset_state = Arc::clone(&state);
tokio::spawn(async move {
if !reset_after.is_zero() {
tokio::time::sleep(reset_after).await;
}
reset_state.set_live2d_talking(false);
});
if resp.error.is_some() {
Ok(json_response(StatusCode::INTERNAL_SERVER_ERROR, &resp))
@@ -2206,6 +2369,21 @@ fn chat_audio_file_route(
)
}
/// GET /api/live2d/talking — 返回当前 Live2D 说话状态(设备端 Firefox 轮询)
fn live2d_talking_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "live2d" / "talking")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
Ok::<_, Infallible>(json_response(
StatusCode::OK,
&serde_json::json!({ "talking": state.live2d_talking() }),
))
})
}
// ── AI 模型管理 API (M2.1) ──
/// GET /api/models — 列出所有模型
@@ -2345,427 +2523,3 @@ fn json_response<T: Serialize>(status: StatusCode, payload: &T) -> warp::reply::
warp::reply::with_status(warp::reply::json(payload), status).into_response()
}
const WEB_UI_HTML: &str = r##"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Showen 控制台</title>
<style>
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#242836;--border:#2e3345;--text:#e4e6ef;--muted:#8b8fa3;--accent:#6366f1;--accent-glow:rgba(99,102,241,.15);--green:#22c55e;--amber:#f59e0b;--red:#ef4444;--radius:12px}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;min-height:100vh}
::selection{background:var(--accent);color:#fff}
::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.app{max-width:1200px;margin:0 auto;padding:16px 20px}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 0;border-bottom:1px solid var(--border);margin-bottom:16px;flex-wrap:wrap}
header h1{font-size:20px;font-weight:600;background:linear-gradient(135deg,#6366f1,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
header .badge{font-size:11px;padding:3px 10px;border-radius:99px;background:var(--accent-glow);color:var(--accent);border:1px solid rgba(99,102,241,.3)}
.header-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.header-link{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:8px 16px;border-radius:8px;color:#fff;font:inherit;cursor:pointer;transition:all .15s;text-decoration:none}
.modal-backdrop{position:fixed;inset:0;background:rgba(10,12,18,.72);backdrop-filter:blur(10px);display:none;align-items:center;justify-content:center;padding:20px;z-index:998}
.modal-backdrop.open{display:flex}
.download-modal{width:min(100%,420px);background:linear-gradient(180deg,rgba(36,40,54,.98),rgba(26,29,39,.98));border:1px solid rgba(99,102,241,.28);border-radius:20px;padding:22px;box-shadow:0 24px 60px rgba(0,0,0,.4)}
.download-modal h2{font-size:22px;margin-bottom:8px}
.download-modal p{color:var(--muted);margin-bottom:18px}
.download-qr{display:flex;justify-content:center;align-items:center;padding:14px;background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.2);border-radius:16px;margin-bottom:16px}
.download-qr img{display:block;width:180px;height:180px;border-radius:12px;background:#fff}
.download-actions{display:flex;gap:10px;flex-wrap:wrap}
.download-actions>*{flex:1}
.download-meta{font-size:13px;color:var(--text);margin-bottom:14px;padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid var(--border)}
.download-meta.warn{color:var(--amber);border-color:rgba(245,158,11,.3);background:rgba(245,158,11,.08)}
.download-note{font-size:12px;color:var(--muted);margin-top:12px}
.tabs{display:flex;gap:4px;background:var(--surface);border-radius:var(--radius);padding:4px;margin-bottom:16px;overflow-x:auto}
.tab{padding:8px 16px;border-radius:8px;border:0;background:0;color:var(--muted);cursor:pointer;font:inherit;white-space:nowrap;transition:all .2s}
.tab:hover{color:var(--text);background:var(--surface2)}
.tab.active{color:#fff;background:var(--accent);box-shadow:0 2px 8px rgba(99,102,241,.3)}
.panel{display:none}.panel.active{display:block}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px}
.card h2{font-size:14px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.stat{background:var(--surface2);border-radius:8px;padding:10px 12px}
.stat .label{font-size:11px;color:var(--muted);display:block}.stat .val{font-size:16px;font-weight:600;color:var(--accent)}
.stat .val.ok{color:var(--green)}.stat .val.warn{color:var(--amber)}.stat .val.err{color:var(--red)}
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
button{border:0;border-radius:8px;padding:8px 16px;font:inherit;cursor:pointer;transition:all .15s}
.btn{background:var(--accent);color:#fff}.btn:hover{opacity:.85;transform:translateY(-1px)}
.btn-s{background:var(--surface2);color:var(--text);border:1px solid var(--border)}.btn-s:hover{border-color:var(--accent);color:var(--accent)}
.btn-d{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2)}.btn-d:hover{background:rgba(239,68,68,.2)}
.btn-g{background:rgba(34,197,94,.1);color:var(--green);border:1px solid rgba(34,197,94,.2)}.btn-g:hover{background:rgba(34,197,94,.2)}
input,select,textarea{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface2);color:var(--text);font:inherit;outline:0;transition:border-color .2s}
input:focus,select:focus,textarea:focus{border-color:var(--accent)}
textarea{min-height:200px;font-family:"Fira Code",monospace;font-size:13px;resize:vertical}
label{display:block;font-size:12px;color:var(--muted);margin:8px 0 4px}
.row{display:flex;gap:8px;align-items:end}.row>*{flex:1}
.list{max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface2)}
.item{display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border);gap:8px;font-size:13px}
.item:last-child{border-bottom:0}
.item .name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.item .meta{color:var(--muted);font-size:12px;white-space:nowrap}
.item .dir-icon{color:var(--amber);margin-right:4px}
.toast{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:8px;color:#fff;font-size:13px;display:none;z-index:999;backdrop-filter:blur(8px);max-width:360px}
.toast.ok{background:rgba(34,197,94,.9)}.toast.err{background:rgba(239,68,68,.9)}
.file-breadcrumb{display:flex;gap:4px;align-items:center;margin-bottom:10px;font-size:13px;flex-wrap:wrap}
.file-breadcrumb span{color:var(--muted);cursor:pointer;padding:2px 6px;border-radius:4px}.file-breadcrumb span:hover{background:var(--surface2);color:var(--text)}
.file-breadcrumb .sep{color:var(--border);cursor:default;padding:0}.file-breadcrumb .sep:hover{background:none;color:var(--border)}
.file-toolbar{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;align-items:center}
.file-toolbar select{width:auto;min-width:120px}
.checkbox-label{display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer}
.checkbox-label input[type=checkbox]{width:auto;accent-color:var(--accent)}
@media (max-width:720px){.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr}header h1{font-size:16px}}
</style>
</head>
<body>
<div id="toast" class="toast"></div>
<div id="download-modal" class="modal-backdrop" onclick="closeDownloadModal(event)">
<div class="download-modal">
<h2>下载 Showen App</h2>
<p id="download-modal-desc">扫描二维码或点击下载,支持蓝牙配网和远程控制</p>
<div id="download-meta" class="download-meta">正在获取 App 信息...</div>
<div class="download-qr"><img id="download-qr-image" alt="Showen App 下载二维码"></div>
<div class="download-actions">
<a id="download-apk-link" class="btn header-link" href="/download/showen-app.apk" download>下载 APK</a>
<button class="btn-s" type="button" onclick="closeDownloadModal()">关闭</button>
</div>
<div class="download-note">若二维码加载失败,可直接点击下载按钮获取 APK。</div>
</div>
</div>
<div class="app">
<header>
<h1>Showen 控制台</h1>
<div class="header-actions">
<button class="btn" type="button" onclick="openDownloadModal()">下载 App</button>
<span class="badge" id="ws-badge">WS 连接中...</span>
</div>
</header>
<div class="tabs">
<button class="tab active" data-tab="control">播放控制</button>
<button class="tab" data-tab="videos">视频管理</button>
<button class="tab" data-tab="files">文件管理</button>
<button class="tab" data-tab="wifi">网络设置</button>
<button class="tab" data-tab="settings">系统配置</button>
</div>
<!-- 播放控制 -->
<section class="panel active" id="panel-control">
<div class="grid">
<div class="card">
<h2>播放状态</h2>
<div class="stats">
<div class="stat"><span class="label">状态</span><span id="st-state" class="val">--</span></div>
<div class="stat"><span class="label">当前视频</span><span id="st-video" class="val">--</span></div>
<div class="stat"><span class="label">索引</span><span id="st-index" class="val">--</span></div>
<div class="stat"><span class="label">列表长度</span><span id="st-len" class="val">--</span></div>
</div>
</div>
<div class="card">
<h2>播放操作</h2>
<div class="btns">
<button class="btn-s" onclick="wsReady?wsCmd({cmd:'prev'}):api('POST','/api/previous')">上一个</button>
<button class="btn" onclick="wsReady?wsCmd({cmd:'play'}):api('POST','/api/play')">播放</button>
<button class="btn-s" onclick="wsReady?wsCmd({cmd:'pause'}):api('POST','/api/pause')">暂停</button>
<button class="btn" onclick="wsReady?wsCmd({cmd:'next'}):api('POST','/api/next')">下一个</button>
</div>
<div class="row" style="margin-top:12px">
<div><label>跳转到索引</label><input id="goto-idx" type="number" min="0" placeholder="0"></div>
<div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="gotoVideo()">跳转</button></div>
</div>
</div>
<div class="card" style="grid-column:1/-1">
<h2>触发器</h2>
<div class="btns">
<button class="btn-s" onclick="triggerPreset('voice','name')">语音唤醒</button>
<button class="btn-s" onclick="triggerPreset('button','button1')">按钮 1</button>
<button class="btn-s" onclick="triggerPreset('button','button2')">按钮 2</button>
<button class="btn-s" onclick="triggerPreset('sensor','touch')">触摸感应</button>
</div>
<div class="row" style="margin-top:10px">
<div><label>触发器名称</label><input id="tr-name" type="text" placeholder="voice"></div>
<div><label>触发器值</label><input id="tr-value" type="text" placeholder="name"></div>
<div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="triggerCustom()">发送</button></div>
</div>
</div>
</div>
</section>
<!-- 视频管理 -->
<section class="panel" id="panel-videos">
<div class="grid">
<div class="card">
<h2>上传视频</h2>
<input id="upload-file" type="file" accept="video/*" multiple>
<div class="btns"><button class="btn" onclick="uploadVideos()">上传</button></div>
</div>
<div class="card">
<h2>设备视频文件</h2>
<div id="video-list" class="list"><div class="item"><span class="name">加载中...</span></div></div>
<div class="btns"><button class="btn-s" onclick="loadVideoList()">刷新</button></div>
</div>
</div>
</section>
<!-- 文件管理 -->
<section class="panel" id="panel-files">
<div class="card">
<h2>文件管理器</h2>
<div class="file-toolbar">
<select id="fm-dir" onchange="fmNavigate('')">
<option value="videos">视频目录</option>
<option value="configs">配置目录</option>
<option value="plugins">插件目录</option>
</select>
<button class="btn-s" onclick="fmRefresh()">刷新</button>
<button class="btn-s" onclick="fmMkdir()">新建文件夹</button>
<button class="btn" onclick="document.getElementById('fm-upload-input').click()">上传文件</button>
<input id="fm-upload-input" type="file" multiple style="display:none" onchange="fmUpload(this)">
</div>
<div id="fm-breadcrumb" class="file-breadcrumb"></div>
<div id="fm-list" class="list" style="max-height:400px"><div class="item"><span class="name">选择目录后加载...</span></div></div>
<div class="btns" style="margin-top:8px">
<button class="btn-d" id="fm-del-btn" onclick="fmDeleteSelected()" style="display:none">删除选中</button>
</div>
</div>
</section>
<!-- 网络设置 -->
<section class="panel" id="panel-wifi">
<div class="grid">
<div class="card">
<h2>网络状态</h2>
<div class="stats">
<div class="stat"><span class="label">WiFi</span><span id="wifi-connected" class="val">--</span></div>
<div class="stat"><span class="label">SSID</span><span id="wifi-ssid" class="val">--</span></div>
<div class="stat"><span class="label">IP</span><span id="wifi-ip" class="val">--</span></div>
<div class="stat"><span class="label">BLE</span><span id="ble-status" class="val">--</span></div>
</div>
<div class="btns"><button class="btn-s" onclick="loadWifiStatus();loadBleStatus()">刷新状态</button></div>
</div>
<div class="card">
<h2>WiFi 扫描</h2>
<div id="wifi-list" class="list"><div class="item"><span class="name">点击扫描搜索附近网络</span></div></div>
<div class="btns"><button class="btn" onclick="scanWifi()">扫描网络</button></div>
</div>
<div class="card">
<h2>连接 WiFi</h2>
<label>SSID</label><input id="wifi-ssid-input" type="text" placeholder="网络名称">
<label>密码</label><input id="wifi-pass-input" type="password" placeholder="WiFi 密码">
<div class="btns"><button class="btn" onclick="connectWifi()">连接</button></div>
</div>
<div class="card">
<h2>热点 / BLE</h2>
<label>热点名称</label><input id="ap-ssid" type="text" value="Showen">
<label>热点密码</label><input id="ap-pass" type="text" value="12345678">
<div class="btns">
<button class="btn-g" onclick="startAP()">开启热点</button>
<button class="btn-d" onclick="stopAP()">关闭热点</button>
</div>
<label>BLE 设备名</label><input id="ble-name" type="text" value="Showen">
<div class="btns">
<button class="btn-s" onclick="startBLE()">启动 BLE</button>
<button class="btn-d" onclick="stopBLE()">停止 BLE</button>
</div>
</div>
</div>
</section>
<!-- 系统配置 -->
<section class="panel" id="panel-settings">
<div class="grid">
<div class="card" style="grid-column:1/-1">
<h2>配置文件切换</h2>
<div class="row">
<div><label>当前配置</label><select id="cfg-select"><option>加载中...</option></select></div>
<div style="flex:0"><label>&nbsp;</label><button class="btn" onclick="switchConfig()">切换</button></div>
</div>
</div>
<div class="card">
<h2>显示设置</h2>
<div id="display-form"></div>
<div class="btns"><button class="btn" onclick="saveDisplay()">保存显示设置</button></div>
</div>
<div class="card">
<h2>配置编辑器</h2>
<textarea id="cfg-editor" placeholder="加载中..."></textarea>
<div class="btns">
<button class="btn-s" onclick="loadConfig()">重新加载</button>
<button class="btn-s" onclick="formatConfig()">格式化</button>
<button class="btn" onclick="saveConfig()">保存配置</button>
</div>
</div>
</div>
</section>
</div>
<script>
var cachedConfig=null,ws=null,wsReady=false;
var fmCurrentDir='videos',fmCurrentPath='';
function $(id){return document.getElementById(id)}
function toast(msg,err){var el=$('toast');el.textContent=msg;el.className='toast '+(err?'err':'ok');el.style.display='block';clearTimeout(el._t);el._t=setTimeout(function(){el.style.display='none'},3000)}
function formatBytes(size){if(size===undefined||size===null||isNaN(size))return '--';if(size<1024)return size+' B';if(size<1048576)return(size/1024).toFixed(1)+' KB';if(size<1073741824)return(size/1048576).toFixed(1)+' MB';return(size/1073741824).toFixed(1)+' GB'}
function updateDownloadModal(info){
var meta=$('download-meta'),link=$('download-apk-link'),qr=$('download-qr-image'),desc=$('download-modal-desc');
if(!info||!info.apk_available){
meta.textContent='APK 尚未发布,请稍后再试';
meta.className='download-meta warn';
link.style.display='none';
qr.style.display='none';
qr.removeAttribute('src');
desc.textContent='当前暂未提供可下载的 APK 安装包';
return;
}
var url=location.origin+(info.download_url||'/download/showen-app.apk');
meta.textContent='版本 '+(info.version||'0.1.0')+' · '+formatBytes(info.apk_size);
meta.className='download-meta';
link.style.display='inline-flex';
link.href=info.download_url||'/download/showen-app.apk';
qr.style.display='block';
qr.src='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(url);
desc.textContent='扫描二维码或点击下载,支持蓝牙配网和远程控制';
}
function openDownloadModal(){
var modal=$('download-modal');
$('download-meta').textContent='正在获取 App 信息...';
$('download-meta').className='download-meta';
$('download-apk-link').style.display='none';
$('download-qr-image').style.display='none';
$('download-qr-image').removeAttribute('src');
$('download-modal-desc').textContent='扫描二维码或点击下载,支持蓝牙配网和远程控制';
modal.classList.add('open');
fetch('/api/app/info').then(function(r){if(!r.ok)throw new Error('加载 App 信息失败');return r.json()}).then(updateDownloadModal).catch(function(){
$('download-meta').textContent='APK 信息加载失败,请稍后再试';
$('download-meta').className='download-meta warn';
$('download-apk-link').style.display='none';
$('download-qr-image').style.display='none';
})
}
function closeDownloadModal(ev){if(ev&&ev.target!==$('download-modal'))return;$('download-modal').classList.remove('open')}
document.addEventListener('keydown',function(ev){if(ev.key==='Escape')closeDownloadModal()})
// Tabs
document.querySelectorAll('.tab').forEach(function(t){t.onclick=function(){document.querySelectorAll('.tab').forEach(function(e){e.classList.remove('active')});document.querySelectorAll('.panel').forEach(function(e){e.classList.remove('active')});t.classList.add('active');$('panel-'+t.dataset.tab).classList.add('active');if(t.dataset.tab==='videos')loadVideoList();if(t.dataset.tab==='files')fmRefresh();if(t.dataset.tab==='wifi'){loadWifiStatus();loadBleStatus()}if(t.dataset.tab==='settings'&&!cachedConfig){loadConfig();loadAvailableConfigs()}}});
// WebSocket
function connectWS(){var proto=location.protocol==='https:'?'wss:':'ws:';ws=new WebSocket(proto+'//'+location.host+'/ws');ws.onopen=function(){wsReady=true;$('ws-badge').textContent='WS 已连接';$('ws-badge').style.borderColor='rgba(34,197,94,.4)';$('ws-badge').style.color='#22c55e';$('ws-badge').style.background='rgba(34,197,94,.1)'};ws.onclose=function(){wsReady=false;$('ws-badge').textContent='WS 断开';$('ws-badge').style.borderColor='rgba(239,68,68,.3)';$('ws-badge').style.color='#ef4444';$('ws-badge').style.background='rgba(239,68,68,.1)';setTimeout(connectWS,2000)};ws.onerror=function(){wsReady=false};ws.onmessage=function(ev){try{var msg=JSON.parse(ev.data);if(msg.type==='status_update')applyStatus(msg.data);else if(msg.type==='state_update')toast('场景: '+msg.data.old_state+' -> '+msg.data.new_state);else if(msg.type==='wifi_update')applyWifi(msg.data);else if(msg.type==='ble_update')applyBle(msg.data);else if(msg.type==='config_update')cachedConfig=msg.data}catch(e){}}}
function wsCmd(obj){if(wsReady)ws.send(JSON.stringify(obj));else toast('WebSocket 未连接',true)}
// API
function api(method,path,body){var opts={method:method,headers:{}};if(body!==undefined){if(typeof body==='string'){opts.headers['Content-Type']='application/json';opts.body=body}else if(body instanceof FormData){opts.body=body}else{opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(body)}}return fetch(path,opts).then(function(r){return r.json().then(function(d){if(!r.ok)throw d;return d})}).then(function(d){if(d.message)toast(d.message,d.status==='error');return d}).catch(function(e){toast((e&&e.message)||'请求失败',true);throw e})}
// Status
function applyStatus(d){var el=$('st-state');if(!d.running){el.textContent='已停止';el.className='val warn'}else if(d.paused){el.textContent='已暂停';el.className='val warn'}else{el.textContent='播放中';el.className='val ok'}$('st-video').textContent=d.current_video||'无';$('st-index').textContent=d.current_index;$('st-len').textContent=d.playlist_length}
function refreshStatus(){fetch('/api/status').then(function(r){return r.json()}).then(applyStatus).catch(function(){})}
function applyWifi(d){if(d.connected!==undefined){$('wifi-connected').textContent=d.connected?'已连接':'未连接';$('wifi-connected').className=d.connected?'val ok':'val err';$('wifi-ssid').textContent=d.ssid||'--';$('wifi-ip').textContent=d.ip||'--'}}
function applyBle(d){if(d.ready!==undefined){var el=$('ble-status');el.textContent=d.ready?'运行中':'未就绪';el.className=d.ready?'val ok':'val warn'}}
// Controls
function gotoVideo(){var idx=$('goto-idx').value;if(idx===''){toast('请输入索引',true);return}if(wsReady)wsCmd({cmd:'goto',index:parseInt(idx,10)});else api('POST','/api/goto/'+idx)}
function triggerPreset(n,v){if(wsReady)wsCmd({cmd:'trigger',name:n,value:v||''});else api('POST','/api/trigger/'+encodeURIComponent(n)+'/'+encodeURIComponent(v||''))}
function triggerCustom(){var n=$('tr-name').value,v=$('tr-value').value;if(!n){toast('请输入触发器名',true);return}triggerPreset(n,v)}
// Videos
function loadVideoList(){fetch('/api/videos').then(function(r){return r.json()}).then(function(files){var el=$('video-list');if(!files.length){el.innerHTML='<div class="item"><span class="name">目录中没有视频文件</span></div>';return}el.innerHTML=files.map(function(f){var sz=f.size<1048576?(f.size/1024).toFixed(1)+' KB':(f.size/1048576).toFixed(1)+' MB';return '<div class="item"><span class="name">'+esc(f.name)+'</span><span class="meta">'+sz+'</span><button class="btn-d" onclick="deleteVideo(\''+js(f.name)+'\')">删除</button></div>'}).join('')}).catch(function(){toast('加载视频列表失败',true)})}
function uploadVideos(){var input=$('upload-file');if(!input.files.length){toast('请先选择文件',true);return}var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);fetch('/api/videos/upload',{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';loadVideoList()}).catch(function(){toast('上传失败',true)})}
function deleteVideo(name){if(!confirm('确定删除 '+name+' ?'))return;fetch('/api/videos/'+encodeURIComponent(name),{method:'DELETE'}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');loadVideoList()}).catch(function(){toast('删除失败',true)})}
// File Manager
function fmRefresh(){fmNavigate(fmCurrentPath)}
function fmNavigate(subpath){
fmCurrentDir=$('fm-dir').value;fmCurrentPath=subpath||'';
var url='/api/files/'+fmCurrentDir;if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);
// breadcrumb
var bc=$('fm-breadcrumb');var parts=fmCurrentPath?fmCurrentPath.split('/'):[];
var html='<span onclick="fmNavigate(\'\')">'+({'videos':'视频目录','configs':'配置目录','plugins':'插件目录'}[fmCurrentDir]||fmCurrentDir)+'</span>';
var acc='';
for(var i=0;i<parts.length;i++){if(parts[i]){acc+=(acc?'/':'')+parts[i];html+='<span class="sep">/</span><span onclick="fmNavigate(\''+js(acc)+'\')">'+esc(parts[i])+'</span>'}}
bc.innerHTML=html;
fetch(url).then(function(r){return r.json()}).then(function(entries){
var el=$('fm-list');
if(entries.status==='error'){el.innerHTML='<div class="item"><span class="name">'+esc(entries.message)+'</span></div>';return}
if(!entries.length){el.innerHTML='<div class="item"><span class="name">空目录</span></div>';$('fm-del-btn').style.display='none';return}
var html='';
for(var i=0;i<entries.length;i++){
var e=entries[i];var sz=e.is_dir?'':e.size<1048576?(e.size/1024).toFixed(1)+' KB':(e.size/1048576).toFixed(1)+' MB';
var fp=fmCurrentPath?(fmCurrentPath+'/'+e.name):e.name;
html+='<div class="item">';
html+='<label class="checkbox-label"><input type="checkbox" class="fm-check" data-path="'+esc(fp)+'" data-isdir="'+(e.is_dir?'1':'0')+'"></label>';
if(e.is_dir){html+='<span class="dir-icon">&#128193;</span><span class="name" style="cursor:pointer;color:var(--amber)" onclick="fmNavigate(\''+js(fp)+'\')">'+esc(e.name)+'</span>'}
else{html+='<span class="name">'+esc(e.name)+'</span>'}
html+='<span class="meta">'+sz+'</span>';
if(!e.is_dir){html+='<button class="btn-s" onclick="fmDownload(\''+js(fp)+'\')">下载</button>'}
html+='</div>'
}
el.innerHTML=html;
// 监听 checkbox 变化
el.querySelectorAll('.fm-check').forEach(function(cb){cb.onchange=fmUpdateDelBtn});
fmUpdateDelBtn()
}).catch(function(){toast('加载文件列表失败',true)})
}
function fmUpdateDelBtn(){var cbs=document.querySelectorAll('.fm-check:checked');$('fm-del-btn').style.display=cbs.length?'':'none';$('fm-del-btn').textContent='删除选中 ('+cbs.length+')'}
function fmDeleteSelected(){
var cbs=document.querySelectorAll('.fm-check:checked');if(!cbs.length)return;
if(!confirm('确定删除 '+cbs.length+' 个项目?'))return;
var promises=[];
cbs.forEach(function(cb){
var p=cb.dataset.path;
promises.push(fetch('/api/files/'+fmCurrentDir+'/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p})}).then(function(r){return r.json()}))
});
Promise.all(promises).then(function(results){
var ok=0,fail=0;results.forEach(function(r){if(r.status==='ok')ok++;else fail++});
toast('删除完成: '+ok+' 成功'+(fail?' / '+fail+' 失败':''),fail>0);fmRefresh()
})
}
function fmDownload(path){window.open('/api/files/'+fmCurrentDir+'/download?path='+encodeURIComponent(path),'_blank')}
function fmUpload(input){
if(!input.files.length)return;
var fd=new FormData();for(var i=0;i<input.files.length;i++)fd.append('file',input.files[i],input.files[i].name);
var url='/api/files/'+fmCurrentDir+'/upload';if(fmCurrentPath)url+='?path='+encodeURIComponent(fmCurrentPath);
fetch(url,{method:'POST',body:fd}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');input.value='';fmRefresh()}).catch(function(){toast('上传失败',true)})
}
function fmMkdir(){
var name=prompt('输入新文件夹名称:');if(!name)return;
var path=fmCurrentPath?(fmCurrentPath+'/'+name):name;
fetch('/api/files/'+fmCurrentDir+'/mkdir',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:path})}).then(function(r){return r.json()}).then(function(d){toast(d.message,d.status==='error');fmRefresh()}).catch(function(){toast('创建失败',true)})
}
// WiFi
function loadWifiStatus(){fetch('/api/wifi/status').then(function(r){return r.json()}).then(applyWifi).catch(function(){})}
function scanWifi(){$('wifi-list').innerHTML='<div class="item"><span class="name">扫描中...</span></div>';fetch('/api/wifi/scan').then(function(r){return r.json()}).then(function(list){if(!list.length){$('wifi-list').innerHTML='<div class="item"><span class="name">未发现 WiFi</span></div>';return}$('wifi-list').innerHTML=list.map(function(n){return '<div class="item"><span class="name">'+esc(n.ssid||'隐藏网络')+'</span><span class="meta">'+esc(String(n.signal||0))+' / '+esc(n.security||'OPEN')+'</span><button class="btn-s" onclick="selectWifi(\''+js(n.ssid||'')+'\')">选择</button></div>'}).join('')}).catch(function(){toast('扫描失败',true)})}
function selectWifi(ssid){$('wifi-ssid-input').value=ssid}
function connectWifi(){var s=$('wifi-ssid-input').value,p=$('wifi-pass-input').value;if(!s){toast('请输入 WiFi 名称',true);return}if(wsReady)wsCmd({cmd:'connect',ssid:s,password:p});else api('POST','/api/wifi/connect',{ssid:s,password:p}).then(function(){setTimeout(loadWifiStatus,1500)})}
function startAP(){var s=$('ap-ssid').value||'showen',p=$('ap-pass').value||'12345678';if(p.length<8){toast('热点密码至少 8 位',true);return}if(wsReady)wsCmd({cmd:'ap_start',ssid:s,password:p});else api('POST','/api/wifi/ap/start',{ssid:s,password:p})}
function stopAP(){if(wsReady)wsCmd({cmd:'ap_stop'});else api('POST','/api/wifi/ap/stop')}
function loadBleStatus(){fetch('/api/ble/status').then(function(r){return r.json()}).then(function(d){var el=$('ble-status');if(d.running){el.textContent='运行中 / '+(d.device_name||'showen');el.className='val ok'}else{el.textContent='未就绪';el.className='val warn'}}).catch(function(){})}
function startBLE(){api('POST','/api/ble/start',{device_name:$('ble-name').value||'showen'}).then(loadBleStatus)}
function stopBLE(){api('POST','/api/ble/stop').then(loadBleStatus)}
// Config switch
function loadAvailableConfigs(){fetch('/api/config/available').then(function(r){return r.json()}).then(function(d){var sel=$('cfg-select');sel.innerHTML='';d.configs.forEach(function(f){var opt=document.createElement('option');opt.value=f;opt.textContent=f;if(f===d.active)opt.selected=true;sel.appendChild(opt)})}).catch(function(){})}
function switchConfig(){var f=$('cfg-select').value;if(!f){toast('请选择配置',true);return}if(!confirm('确定切换到 '+f+' ?'))return;api('POST','/api/config/switch',{filename:f}).then(function(){loadConfig();loadAvailableConfigs()})}
// Config
function loadConfig(){fetch('/api/config').then(function(r){return r.json()}).then(function(cfg){cachedConfig=cfg;$('cfg-editor').value=JSON.stringify(cfg,null,2);renderDisplay(cfg.display)}).catch(function(){toast('加载配置失败',true)})}
function formatConfig(){try{$('cfg-editor').value=JSON.stringify(JSON.parse($('cfg-editor').value),null,2)}catch(_){toast('JSON 格式错误',true)}}
function saveConfig(){var raw=$('cfg-editor').value;try{JSON.parse(raw)}catch(_){toast('JSON 格式错误',true);return}api('POST','/api/config',raw).then(loadConfig)}
function renderDisplay(d){if(!d)return;var h='';h+='<label class="checkbox-label"><input id="d-fullscreen" type="checkbox" '+(d.fullscreen?'checked':'')+'> 全屏模式</label>';h+='<label>窗口标题</label><input id="d-title" type="text" value="'+ea(d.window_title||'')+'">';h+='<label>旋转角度</label><input id="d-rotation" type="number" value="'+ea(String(d.rotation||0))+'">';h+='<label>渲染宽度</label><input id="d-render-width" type="number" value="'+ea(String(d.render_width||1024))+'">';h+='<label>渲染高度</label><input id="d-render-height" type="number" value="'+ea(String(d.render_height||1024))+'">';h+='<label>色键下限 (H,S,V)</label><input id="d-ck-min" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_min?d.chroma_key.hsv_min.join(','):'0,0,200'))+'">';h+='<label>色键上限 (H,S,V)</label><input id="d-ck-max" type="text" value="'+ea((d.chroma_key&&d.chroma_key.hsv_max?d.chroma_key.hsv_max.join(','):'180,30,255'))+'">';h+='<label>透视校正点 (JSON)</label><input id="d-points" type="text" value="'+ea(JSON.stringify((d.perspective_correction&&d.perspective_correction.points)||[]))+'">';$('display-form').innerHTML=h}
function saveDisplay(){if(!cachedConfig){loadConfig();return}var n=JSON.parse(JSON.stringify(cachedConfig));n.display.fullscreen=$('d-fullscreen').checked;n.display.window_title=$('d-title').value;n.display.rotation=parseInt($('d-rotation').value||'0',10);n.display.render_width=parseInt($('d-render-width').value||'1024',10);n.display.render_height=parseInt($('d-render-height').value||'1024',10);n.display.chroma_key=n.display.chroma_key||{};n.display.chroma_key.hsv_min=$('d-ck-min').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.chroma_key.hsv_max=$('d-ck-max').value.split(',').map(function(v){return parseInt(v.trim()||'0',10)});n.display.perspective_correction=n.display.perspective_correction||{};try{n.display.perspective_correction.points=JSON.parse($('d-points').value)}catch(_){toast('透视点 JSON 无效',true);return}cachedConfig=n;$('cfg-editor').value=JSON.stringify(n,null,2);saveConfig()}
// Utils
function esc(v){return String(v).replace(/[&<>"]/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]})}
function ea(v){return esc(v).replace(/'/g,'&#39;')}
function js(v){return String(v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")}
connectWS();refreshStatus();
</script>
</body>
</html>"##;

View File

@@ -0,0 +1,148 @@
//! 动画系统:眨眼、嘴部动效、待机呼吸
use super::core_ffi::CubismCore;
use super::model::CubismModel;
use std::time::{Duration, Instant};
/// 动画状态
pub struct AnimationState {
/// 眨眼定时器
last_blink: Instant,
next_blink_interval: Duration,
blink_phase: BlinkPhase,
/// 嘴部状态
talking: bool,
mouth_open: f32,
mouth_target: f32,
last_mouth_update: Instant,
/// 呼吸
breath_phase: f32,
/// 身体摇摆
body_phase: f32,
/// 眼球跟随(简化为固定值)
eye_ball_x: f32,
eye_ball_y: f32,
}
#[derive(Clone, Copy, Debug)]
enum BlinkPhase {
Idle,
Closing(f32), // 0→1
Opening(f32), // 1→0
}
impl Default for AnimationState {
fn default() -> Self {
Self {
last_blink: Instant::now(),
next_blink_interval: Duration::from_secs(3),
blink_phase: BlinkPhase::Idle,
talking: false,
mouth_open: 0.0,
mouth_target: 0.0,
last_mouth_update: Instant::now(),
breath_phase: 0.0,
body_phase: 0.0,
eye_ball_x: 0.0,
eye_ball_y: 0.0,
}
}
}
impl AnimationState {
pub fn set_talking(&mut self, talking: bool) {
self.talking = talking;
if !talking {
self.mouth_target = 0.0;
}
}
/// 更新动画并应用到 model 参数
pub fn update(&mut self, core: &CubismCore, model: &CubismModel, now: Instant, dt: f32) {
// === 呼吸 ===
self.breath_phase += dt * 0.5; // 0.5 Hz
let breath = self.breath_phase.sin() * 0.5 + 0.5;
model.set_param(core, "ParamBreath", breath);
// === 身体摇摆 ===
self.body_phase += dt * 0.3;
let body_x = self.body_phase.sin() * 8.0;
let body_y = self.body_phase.sin() * 0.5;
model.set_param(core, "ParamBodyAngleX", body_x);
model.set_param(core, "ParamBodyAngleY", body_y);
// === 眨眼 ===
self.update_blink(now, core, model);
// === 嘴部 ===
self.update_mouth(now, dt, core, model);
// === 眼球(轻微随机移动)===
self.eye_ball_x = (now.elapsed().as_secs_f32() * 0.3).sin() * 0.3;
self.eye_ball_y = (now.elapsed().as_secs_f32() * 0.2).cos() * 0.2;
model.set_param(core, "ParamEyeBallX", self.eye_ball_x);
model.set_param(core, "ParamEyeBallY", self.eye_ball_y);
}
fn update_blink(&mut self, now: Instant, core: &CubismCore, model: &CubismModel) {
// 先计算新的眨眼状态和当前 eye_open 值,避免借用冲突
let (new_phase, eye_open) = match self.blink_phase {
BlinkPhase::Idle => {
if now.duration_since(self.last_blink) >= self.next_blink_interval {
(BlinkPhase::Closing(0.0), 1.0)
} else {
(BlinkPhase::Idle, 1.0)
}
}
BlinkPhase::Closing(mut t) => {
t += 0.15;
if t >= 1.0 {
(BlinkPhase::Opening(1.0), 0.0)
} else {
(BlinkPhase::Closing(t), 1.0 - t)
}
}
BlinkPhase::Opening(mut t) => {
t -= 0.1;
if t <= 0.0 {
self.last_blink = now;
self.next_blink_interval =
Duration::from_secs(2 + (now.elapsed().as_nanos() % 3) as u64);
(BlinkPhase::Idle, 1.0)
} else {
(BlinkPhase::Opening(t), 1.0 - t)
}
}
};
self.blink_phase = new_phase;
model.set_param(core, "ParamEyeLOpen", eye_open);
model.set_param(core, "ParamEyeROpen", eye_open);
}
fn update_mouth(
&mut self,
now: Instant,
dt: f32,
core: &CubismCore,
model: &CubismModel,
) {
if self.talking {
// 说话时嘴部快速开合
let t = now.elapsed().as_secs_f32();
// 用多个正弦叠加产生自然的变化
let base = (t * 12.0).sin() * 0.4 + (t * 7.0).sin() * 0.3 + (t * 19.0).sin() * 0.2;
self.mouth_target = (base * 0.5 + 0.5).clamp(0.0, 1.0) * 0.9 + 0.1;
} else {
self.mouth_target = 0.0;
}
// 平滑过渡
let lerp_speed = 15.0 * dt;
self.mouth_open += (self.mouth_target - self.mouth_open) * lerp_speed.min(1.0);
model.set_param(core, "ParamMouthOpenY", self.mouth_open);
// 嘴部形态也轻微变化
let form = (now.elapsed().as_secs_f32() * 3.0).sin() * 0.3 * if self.talking { 1.0 } else { 0.0 };
model.set_param(core, "ParamMouthForm", form);
}
}

View File

@@ -0,0 +1,155 @@
//! 资源管理model3.json 解析 + PNG 纹理加载
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
/// model3.json 中的 Groups 条目
#[derive(Deserialize, Clone, Debug)]
pub struct ModelGroup {
#[serde(rename = "Target")]
pub target: String,
#[serde(rename = "Name")]
pub name: String,
#[serde(rename = "Ids")]
pub ids: Vec<String>,
}
/// model3.json 文件引用
#[derive(Deserialize, Clone, Debug)]
pub struct FileReferences {
#[serde(rename = "Moc")]
pub moc: String,
#[serde(rename = "Textures", default)]
pub textures: Vec<String>,
#[serde(rename = "Physics", default)]
pub physics: Option<String>,
#[serde(rename = "Pose", default)]
pub pose: Option<String>,
#[serde(rename = "DisplayInfo", default)]
pub display_info: Option<String>,
}
/// model3.json 根结构
#[derive(Deserialize, Clone, Debug)]
pub struct Model3Json {
#[serde(rename = "Version")]
pub version: i32,
#[serde(rename = "FileReferences")]
pub file_references: FileReferences,
#[serde(rename = "Groups", default)]
pub groups: Vec<ModelGroup>,
}
impl Model3Json {
/// 从文件解析
pub fn from_path(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("读取 model3.json 失败: {}", path.display()))?;
let json: Model3Json = serde_json::from_str(&text)
.with_context(|| format!("解析 model3.json 失败: {}", path.display()))?;
Ok(json)
}
/// 获取 LipSync 参数 ID 列表
pub fn lip_sync_ids(&self) -> Vec<String> {
self.groups
.iter()
.find(|g| g.target == "Parameter" && g.name == "LipSync")
.map(|g| g.ids.clone())
.unwrap_or_else(|| vec!["ParamMouthOpenY".to_string()])
}
/// 获取 EyeBlink 参数 ID 列表
pub fn eye_blink_ids(&self) -> Vec<String> {
self.groups
.iter()
.find(|g| g.target == "Parameter" && g.name == "EyeBlink")
.map(|g| g.ids.clone())
.unwrap_or_else(|| {
vec!["ParamEyeLOpen".to_string(), "ParamEyeROpen".to_string()]
})
}
}
/// 加载的纹理图像RGBA
pub struct TextureImage {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// 加载 PNG 纹理(使用 image crate
pub fn load_texture(path: &Path) -> Result<TextureImage> {
let img = image::open(path)
.with_context(|| format!("加载纹理失败: {}", path.display()))?
.to_rgba8();
let width = img.width();
let height = img.height();
let rgba = img.into_raw();
Ok(TextureImage {
width,
height,
rgba,
})
}
/// 加载 model3.json 引用的所有纹理(返回按 texture_00, texture_01 顺序的列表)
pub fn load_all_textures(
model3_dir: &Path,
file_refs: &FileReferences,
) -> Result<Vec<TextureImage>> {
let mut textures = Vec::new();
for tex_rel in &file_refs.textures {
let tex_path = model3_dir.join(tex_rel);
let img = load_texture(&tex_path)?;
println!(
"[Live2D] 纹理加载: {} ({}x{})",
tex_rel, img.width, img.height
);
textures.push(img);
}
Ok(textures)
}
/// 解析 model3.json 并返回完整路径集
pub struct ModelAssetPaths {
pub model3_json: PathBuf,
pub model3_dir: PathBuf,
pub moc3: PathBuf,
pub textures: Vec<PathBuf>,
}
/// 从 model3.json 相对路径(如 "haru/Haru.model3.json")解析资源路径
pub fn resolve_asset_paths(
live2d_base_dir: &Path,
model3_json_rel: &str,
) -> Result<ModelAssetPaths> {
let model3_json = live2d_base_dir.join(model3_json_rel);
if !model3_json.exists() {
return Err(anyhow!(
"model3.json 不存在: {}",
model3_json.display()
));
}
let model3_dir = model3_json
.parent()
.ok_or_else(|| anyhow!("无法获取 model3.json 的父目录"))?
.to_path_buf();
let model3 = Model3Json::from_path(&model3_json)?;
let moc3 = model3_dir.join(&model3.file_references.moc);
let textures = model3
.file_references
.textures
.iter()
.map(|t| model3_dir.join(t))
.collect();
Ok(ModelAssetPaths {
model3_json,
model3_dir,
moc3,
textures,
})
}

View File

@@ -0,0 +1,269 @@
//! Cubism Core FFI 绑定
//!
//! 直接对应 `Live2DCubismCore.h` 的 C API。
//! 动态加载 libLive2DCubismCore.so需 libm.so 预加载,因 Core 内部使用 powf
use libloading::{Library, Symbol};
use std::os::raw::{c_char, c_int, c_uchar, c_uint, c_void};
/// moc 对齐要求(字节)
pub const CSM_ALIGNOF_MOC: usize = 64;
/// model 对齐要求(字节)
pub const CSM_ALIGNOF_MODEL: usize = 16;
/// 可绘制常量标志位
pub mod constant_flags {
pub const BLEND_ADDITIVE: u8 = 1 << 0;
pub const BLEND_MULTIPLICATIVE: u8 = 1 << 1;
pub const IS_DOUBLE_SIDED: u8 = 1 << 2;
pub const IS_INVERTED_MASK: u8 = 1 << 3;
}
/// 可绘制动态标志位
pub mod dynamic_flags {
pub const IS_VISIBLE: u8 = 1 << 0;
pub const VISIBILITY_DID_CHANGE: u8 = 1 << 1;
pub const OPACITY_DID_CHANGE: u8 = 1 << 2;
pub const DRAW_ORDER_DID_CHANGE: u8 = 1 << 3;
pub const RENDER_ORDER_DID_CHANGE: u8 = 1 << 4;
pub const VERTEX_POSITIONS_DID_CHANGE: u8 = 1 << 5;
pub const BLEND_COLOR_DID_CHANGE: u8 = 1 << 6;
}
/// 混合模式
pub mod blend_mode {
pub const NORMAL: c_int = 0;
pub const ADDITIVE: c_int = 1;
pub const MULTIPLICATIVE: c_int = 2;
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct CsmVector2 {
pub x: f32,
pub y: f32,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct CsmVector4 {
pub x: f32,
pub y: f32,
pub z: f32,
pub w: f32,
}
/// Cubism Core 动态加载封装
pub struct CubismCore {
_libm: Library,
_lib: Library,
pub csm_get_version: unsafe extern "C" fn() -> c_uint,
pub csm_get_latest_moc_version: unsafe extern "C" fn() -> c_uint,
pub csm_get_moc_version:
unsafe extern "C" fn(address: *const c_void, size: c_uint) -> c_uint,
pub csm_has_moc_consistency:
unsafe extern "C" fn(address: *mut c_void, size: c_uint) -> c_int,
pub csm_revive_moc_in_place:
unsafe extern "C" fn(address: *mut c_void, size: c_uint) -> *mut c_void,
pub csm_get_sizeof_model: unsafe extern "C" fn(moc: *const c_void) -> c_uint,
pub csm_initialize_model_in_place: unsafe extern "C" fn(
moc: *const c_void,
address: *mut c_void,
size: c_uint,
) -> *mut c_void,
pub csm_update_model: unsafe extern "C" fn(model: *mut c_void),
pub csm_get_render_orders: unsafe extern "C" fn(model: *const c_void) -> *const c_int,
pub csm_read_canvas_info: unsafe extern "C" fn(
model: *const c_void,
out_size: *mut CsmVector2,
out_origin: *mut CsmVector2,
out_ppu: *mut f32,
),
pub csm_get_parameter_count: unsafe extern "C" fn(model: *const c_void) -> c_int,
pub csm_get_parameter_ids:
unsafe extern "C" fn(model: *const c_void) -> *const *const c_char,
pub csm_get_parameter_minimum_values:
unsafe extern "C" fn(model: *const c_void) -> *const f32,
pub csm_get_parameter_maximum_values:
unsafe extern "C" fn(model: *const c_void) -> *const f32,
pub csm_get_parameter_default_values:
unsafe extern "C" fn(model: *const c_void) -> *const f32,
pub csm_get_parameter_values: unsafe extern "C" fn(model: *mut c_void) -> *mut f32,
pub csm_get_part_count: unsafe extern "C" fn(model: *const c_void) -> c_int,
pub csm_get_part_opacities: unsafe extern "C" fn(model: *mut c_void) -> *mut f32,
pub csm_get_drawable_count: unsafe extern "C" fn(model: *const c_void) -> c_int,
pub csm_get_drawable_ids:
unsafe extern "C" fn(model: *const c_void) -> *const *const c_char,
pub csm_get_drawable_constant_flags:
unsafe extern "C" fn(model: *const c_void) -> *const c_uchar,
pub csm_get_drawable_dynamic_flags:
unsafe extern "C" fn(model: *const c_void) -> *const c_uchar,
pub csm_get_drawable_blend_modes:
unsafe extern "C" fn(model: *const c_void) -> *const c_int,
pub csm_get_drawable_texture_indices:
unsafe extern "C" fn(model: *const c_void) -> *const c_int,
pub csm_get_drawable_draw_orders:
unsafe extern "C" fn(model: *const c_void) -> *const c_int,
pub csm_get_drawable_opacities:
unsafe extern "C" fn(model: *const c_void) -> *const f32,
pub csm_get_drawable_mask_counts:
unsafe extern "C" fn(model: *const c_void) -> *const c_int,
pub csm_get_drawable_masks:
unsafe extern "C" fn(model: *const c_void) -> *const *const c_int,
pub csm_get_drawable_vertex_counts:
unsafe extern "C" fn(model: *const c_void) -> *const c_int,
pub csm_get_drawable_vertex_positions:
unsafe extern "C" fn(model: *const c_void) -> *const *const CsmVector2,
pub csm_get_drawable_vertex_uvs:
unsafe extern "C" fn(model: *const c_void) -> *const *const CsmVector2,
pub csm_get_drawable_index_counts:
unsafe extern "C" fn(model: *const c_void) -> *const c_int,
pub csm_get_drawable_indices:
unsafe extern "C" fn(model: *const c_void) -> *const *const u16,
pub csm_get_drawable_multiply_colors:
unsafe extern "C" fn(model: *const c_void) -> *const CsmVector4,
pub csm_get_drawable_screen_colors:
unsafe extern "C" fn(model: *const c_void) -> *const CsmVector4,
pub csm_reset_drawable_dynamic_flags: unsafe extern "C" fn(model: *mut c_void),
}
impl CubismCore {
/// 加载 Cubism Core 动态库
///
/// `so_path` 为 libLive2DCubismCore.so 的路径。
/// 内部会先 dlopen libm.soCore 依赖 powf 但 NEEDED 未声明)。
pub fn load(so_path: &str) -> anyhow::Result<Self> {
// 预加载 libmRTLD_GLOBAL 让后续 .so 能解析到 powf
let libm = unsafe {
Library::new("libm.so.6").or_else(|_| Library::new("libm.so.6.1"))?
};
let lib = unsafe { Library::new(so_path)? };
unsafe {
Ok(Self {
csm_get_version: *lib.get(b"csmGetVersion\0")?,
csm_get_latest_moc_version: *lib.get(b"csmGetLatestMocVersion\0")?,
csm_get_moc_version: *lib.get(b"csmGetMocVersion\0")?,
csm_has_moc_consistency: *lib.get(b"csmHasMocConsistency\0")?,
csm_revive_moc_in_place: *lib.get(b"csmReviveMocInPlace\0")?,
csm_get_sizeof_model: *lib.get(b"csmGetSizeofModel\0")?,
csm_initialize_model_in_place: *lib.get(b"csmInitializeModelInPlace\0")?,
csm_update_model: *lib.get(b"csmUpdateModel\0")?,
csm_get_render_orders: *lib.get(b"csmGetRenderOrders\0")?,
csm_read_canvas_info: *lib.get(b"csmReadCanvasInfo\0")?,
csm_get_parameter_count: *lib.get(b"csmGetParameterCount\0")?,
csm_get_parameter_ids: *lib.get(b"csmGetParameterIds\0")?,
csm_get_parameter_minimum_values: *lib
.get(b"csmGetParameterMinimumValues\0")?,
csm_get_parameter_maximum_values: *lib
.get(b"csmGetParameterMaximumValues\0")?,
csm_get_parameter_default_values: *lib
.get(b"csmGetParameterDefaultValues\0")?,
csm_get_parameter_values: *lib.get(b"csmGetParameterValues\0")?,
csm_get_part_count: *lib.get(b"csmGetPartCount\0")?,
csm_get_part_opacities: *lib.get(b"csmGetPartOpacities\0")?,
csm_get_drawable_count: *lib.get(b"csmGetDrawableCount\0")?,
csm_get_drawable_ids: *lib.get(b"csmGetDrawableIds\0")?,
csm_get_drawable_constant_flags: *lib
.get(b"csmGetDrawableConstantFlags\0")?,
csm_get_drawable_dynamic_flags: *lib
.get(b"csmGetDrawableDynamicFlags\0")?,
csm_get_drawable_blend_modes: *lib.get(b"csmGetDrawableBlendModes\0")?,
csm_get_drawable_texture_indices: *lib
.get(b"csmGetDrawableTextureIndices\0")?,
csm_get_drawable_draw_orders: *lib.get(b"csmGetDrawableDrawOrders\0")?,
csm_get_drawable_opacities: *lib.get(b"csmGetDrawableOpacities\0")?,
csm_get_drawable_mask_counts: *lib.get(b"csmGetDrawableMaskCounts\0")?,
csm_get_drawable_masks: *lib.get(b"csmGetDrawableMasks\0")?,
csm_get_drawable_vertex_counts: *lib
.get(b"csmGetDrawableVertexCounts\0")?,
csm_get_drawable_vertex_positions: *lib
.get(b"csmGetDrawableVertexPositions\0")?,
csm_get_drawable_vertex_uvs: *lib.get(b"csmGetDrawableVertexUvs\0")?,
csm_get_drawable_index_counts: *lib.get(b"csmGetDrawableIndexCounts\0")?,
csm_get_drawable_indices: *lib.get(b"csmGetDrawableIndices\0")?,
csm_get_drawable_multiply_colors: *lib
.get(b"csmGetDrawableMultiplyColors\0")?,
csm_get_drawable_screen_colors: *lib
.get(b"csmGetDrawableScreenColors\0")?,
csm_reset_drawable_dynamic_flags: *lib
.get(b"csmResetDrawableDynamicFlags\0")?,
_libm: libm,
_lib: lib,
})
}
}
/// 查询 Core 版本(返回原始 u32格式 0xMMmmpppp
pub fn version(&self) -> u32 {
unsafe { (self.csm_get_version)() }
}
/// 格式化版本号为 "major.minor.patch"
pub fn version_string(&self) -> String {
let v = self.version();
format!(
"{}.{}.{}",
(v >> 24) & 0xFF,
(v >> 16) & 0xFF,
v & 0xFFFF
)
}
}
/// 分配对齐内存(返回对齐指针和底层分配,底层分配需保持存活)
pub fn alloc_aligned(size: usize, align: usize) -> anyhow::Result<AlignedBuffer> {
let layout = std::alloc::Layout::from_size_align(size, align)?;
let ptr = unsafe { std::alloc::alloc(layout) };
if ptr.is_null() {
anyhow::bail!("aligned alloc failed for size={size} align={align}");
}
Ok(AlignedBuffer { ptr, layout })
}
pub struct AlignedBuffer {
ptr: *mut u8,
layout: std::alloc::Layout,
}
impl AlignedBuffer {
pub fn as_ptr(&self) -> *mut u8 {
self.ptr
}
pub fn as_mut_void(&mut self) -> *mut c_void {
self.ptr as *mut c_void
}
pub fn as_void(&self) -> *const c_void {
self.ptr as *const c_void
}
}
impl Drop for AlignedBuffer {
fn drop(&mut self) {
unsafe { std::alloc::dealloc(self.ptr, self.layout) }
}
}
unsafe impl Send for AlignedBuffer {}
unsafe impl Sync for AlignedBuffer {}
/// 将 C 字符串指针转换为 Rust String空指针返回 None
pub unsafe fn cstr_ptr_to_string(ptr: *const c_char) -> Option<String> {
if ptr.is_null() {
return None;
}
let raw = std::slice::from_raw_parts(ptr as *const u8, libc_strlen(ptr));
Some(String::from_utf8_lossy(raw).into_owned())
}
/// 简易 strlen避免依赖 libc crate
unsafe fn libc_strlen(ptr: *const c_char) -> usize {
let mut p = ptr as *const u8;
let mut n = 0;
while *p != 0 {
p = p.add(1);
n += 1;
}
n
}

623
src/plugins/live2d/gl.rs Normal file
View File

@@ -0,0 +1,623 @@
//! OpenGL ES 2.0 / EGL 渲染后端
//!
//! 使用 EGL + X11 窗口创建渲染上下文,用 OpenGL ES 2.0 渲染 Live2D 模型。
use anyhow::{anyhow, Result};
use std::ffi::c_void;
use std::os::raw::{c_char, c_int, c_long, c_uint, c_void as c_void_raw};
// ============ EGL ============
pub mod egl {
use std::os::raw::{c_int, c_uint, c_void};
pub const EGL_SURFACE_TYPE: c_int = 0x3033;
pub const EGL_WINDOW_BIT: c_int = 0x0004;
pub const EGL_BLUE_SIZE: c_int = 0x3022;
pub const EGL_GREEN_SIZE: c_int = 0x3023;
pub const EGL_RED_SIZE: c_int = 0x3024;
pub const EGL_ALPHA_SIZE: c_int = 0x3021;
pub const EGL_RENDERABLE_TYPE: c_int = 0x3040;
pub const EGL_OPENGL_ES2_BIT: c_int = 0x0004;
pub const EGL_NONE: c_int = 0x3038;
pub const EGL_CONTEXT_CLIENT_VERSION: c_int = 0x3098;
pub type EGLDisplay = *mut c_void;
pub type EGLConfig = *mut c_void;
pub type EGLContext = *mut c_void;
pub type EGLSurface = *mut c_void;
pub type NativeDisplayType = *mut c_void;
pub type NativeWindowType = *mut c_void;
pub type EGLBoolean = c_uint;
pub type EGLint = c_int;
/// EGL_DEFAULT_DISPLAY即 NULL
pub fn default_display() -> NativeDisplayType {
std::ptr::null_mut()
}
}
// ============ X11 ============
pub mod x11 {
use std::os::raw::{c_int, c_long, c_uint, c_void};
pub type Display = c_void;
pub type Window = c_long;
#[repr(C)]
pub struct XSetWindowAttributes {
pub background_pixmap: c_long,
pub background_pixel: c_long,
pub border_pixmap: c_long,
pub border_pixel: c_long,
pub bit_gravity: c_int,
pub win_gravity: c_int,
pub backing_store: c_int,
pub backing_planes: c_long,
pub backing_pixel: c_long,
pub save_under: c_int,
pub event_mask: c_long,
pub do_not_propagate_mask: c_long,
pub override_redirect: c_int,
pub colormap: c_long,
pub cursor: c_long,
}
pub const CWOverrideRedirect: c_long = 1 << 9;
pub const CWEventMask: c_long = 1 << 11;
pub const ExposureMask: c_long = 1 << 15;
pub const StructureNotifyMask: c_long = 1 << 17;
}
// ============ GLES2 ============
pub mod gl {
use std::os::raw::{c_int, c_uint};
pub type GLenum = c_uint;
pub type GLuint = c_uint;
pub type GLint = c_int;
pub type GLsizei = c_int;
pub type GLbitfield = c_uint;
pub const GL_FLOAT: GLenum = 0x1406;
pub const GL_ARRAY_BUFFER: GLenum = 0x8892;
pub const GL_ELEMENT_ARRAY_BUFFER: GLenum = 0x8893;
pub const GL_TEXTURE_2D: GLenum = 0x0DE1;
pub const GL_TEXTURE0: GLenum = 0x84C0;
pub const GL_RGBA: GLenum = 0x1908;
pub const GL_UNSIGNED_BYTE: GLenum = 0x1401;
pub const GL_UNSIGNED_SHORT: GLenum = 0x1403;
pub const GL_LINEAR: GLenum = 0x2601;
pub const GL_CLAMP_TO_EDGE: GLenum = 0x812F;
pub const GL_TEXTURE_MIN_FILTER: GLenum = 0x2801;
pub const GL_TEXTURE_MAG_FILTER: GLenum = 0x2800;
pub const GL_TEXTURE_WRAP_S: GLenum = 0x2802;
pub const GL_TEXTURE_WRAP_T: GLenum = 0x2803;
pub const GL_VERTEX_SHADER: GLenum = 0x8B31;
pub const GL_FRAGMENT_SHADER: GLenum = 0x8B30;
pub const GL_COMPILE_STATUS: GLenum = 0x8B81;
pub const GL_LINK_STATUS: GLenum = 0x8B82;
pub const GL_TRIANGLES: GLenum = 0x0004;
pub const GL_BLEND: GLenum = 0x0BE2;
pub const GL_SRC_ALPHA: GLenum = 0x0302;
pub const GL_ONE_MINUS_SRC_ALPHA: GLenum = 0x0303;
pub const GL_ONE: GLenum = 1;
pub const GL_ZERO: GLenum = 0;
pub const GL_SRC_COLOR: GLenum = 0x0300;
pub const GL_COLOR_BUFFER_BIT: GLbitfield = 0x4000;
pub const GL_STATIC_DRAW: GLenum = 0x88E4;
pub const GL_FALSE: GLenum = 0;
pub const GL_NO_ERROR: GLenum = 0;
}
pub use gl::{GLenum, GLuint, GLint, GLsizei};
// ============ 着色器源码 ============
pub const VERT_SHADER_SRC: &str = r#"#version 100
attribute vec4 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
uniform mat4 u_matrix;
void main() {
gl_Position = u_matrix * a_position;
v_texCoord = a_texCoord;
v_texCoord.y = 1.0 - v_texCoord.y;
}
"#;
pub const FRAG_SHADER_SRC: &str = r#"#version 100
precision highp float;
varying vec2 v_texCoord;
uniform sampler2D s_texture0;
uniform vec4 u_baseColor;
uniform vec4 u_multiplyColor;
uniform vec4 u_screenColor;
void main() {
vec4 texColor = texture2D(s_texture0, v_texCoord);
texColor.rgb = texColor.rgb * u_multiplyColor.rgb;
texColor.rgb = texColor.rgb + u_screenColor.rgb - (texColor.rgb * u_screenColor.rgb);
vec4 color = texColor * u_baseColor;
gl_FragColor = vec4(color.rgb * color.a, color.a);
}
"#;
// ============ 动态库函数集合 ============
pub struct Gl {
_lib_egl: libloading::Library,
_lib_gl: libloading::Library,
_lib_x11: libloading::Library,
pub egl_get_display: unsafe extern "C" fn(disp: egl::NativeDisplayType) -> egl::EGLDisplay,
pub egl_initialize: unsafe extern "C" fn(
dpy: egl::EGLDisplay,
major: *mut c_int,
minor: *mut c_int,
) -> egl::EGLBoolean,
pub egl_choose_config: unsafe extern "C" fn(
dpy: egl::EGLDisplay,
attrib_list: *const egl::EGLint,
configs: *mut egl::EGLConfig,
config_size: egl::EGLint,
num_config: *mut egl::EGLint,
) -> egl::EGLBoolean,
pub egl_create_window_surface: unsafe extern "C" fn(
dpy: egl::EGLDisplay,
config: egl::EGLConfig,
win: egl::NativeWindowType,
attrib_list: *const egl::EGLint,
) -> egl::EGLSurface,
pub egl_create_context: unsafe extern "C" fn(
dpy: egl::EGLDisplay,
config: egl::EGLConfig,
share_ctx: egl::EGLContext,
attrib_list: *const egl::EGLint,
) -> egl::EGLContext,
pub egl_make_current: unsafe extern "C" fn(
dpy: egl::EGLDisplay,
draw: egl::EGLSurface,
read: egl::EGLSurface,
ctx: egl::EGLContext,
) -> egl::EGLBoolean,
pub egl_swap_buffers:
unsafe extern "C" fn(dpy: egl::EGLDisplay, surface: egl::EGLSurface) -> egl::EGLBoolean,
pub gl_clear: unsafe extern "C" fn(mask: gl::GLbitfield),
pub gl_clear_color: unsafe extern "C" fn(r: f32, g: f32, b: f32, a: f32),
pub gl_enable: unsafe extern "C" fn(cap: gl::GLenum),
pub gl_blend_func: unsafe extern "C" fn(sfactor: gl::GLenum, dfactor: gl::GLenum),
pub gl_viewport: unsafe extern "C" fn(x: gl::GLint, y: gl::GLint, w: gl::GLsizei, h: gl::GLsizei),
pub gl_create_shader: unsafe extern "C" fn(t: gl::GLenum) -> gl::GLuint,
pub gl_shader_source: unsafe extern "C" fn(
shader: gl::GLuint,
count: gl::GLsizei,
string: *const *const c_char,
length: *const gl::GLint,
),
pub gl_compile_shader: unsafe extern "C" fn(shader: gl::GLuint),
pub gl_get_shaderiv:
unsafe extern "C" fn(shader: gl::GLuint, pname: gl::GLenum, params: *mut gl::GLint),
pub gl_get_shader_info_log: unsafe extern "C" fn(
shader: gl::GLuint,
buf_size: gl::GLsizei,
length: *mut gl::GLsizei,
info_log: *mut c_char,
),
pub gl_create_program: unsafe extern "C" fn() -> gl::GLuint,
pub gl_attach_shader: unsafe extern "C" fn(program: gl::GLuint, shader: gl::GLuint),
pub gl_link_program: unsafe extern "C" fn(program: gl::GLuint),
pub gl_get_programiv:
unsafe extern "C" fn(program: gl::GLuint, pname: gl::GLenum, params: *mut gl::GLint),
pub gl_get_program_info_log: unsafe extern "C" fn(
program: gl::GLuint,
buf_size: gl::GLsizei,
length: *mut gl::GLsizei,
info_log: *mut c_char,
),
pub gl_use_program: unsafe extern "C" fn(program: gl::GLuint),
pub gl_get_attrib_location:
unsafe extern "C" fn(program: gl::GLuint, name: *const c_char) -> gl::GLint,
pub gl_get_uniform_location:
unsafe extern "C" fn(program: gl::GLuint, name: *const c_char) -> gl::GLint,
pub gl_uniform_matrix4fv: unsafe extern "C" fn(
location: gl::GLint,
count: gl::GLsizei,
transpose: gl::GLenum,
value: *const f32,
),
pub gl_uniform1i: unsafe extern "C" fn(location: gl::GLint, v: gl::GLint),
pub gl_uniform4f: unsafe extern "C" fn(location: gl::GLint, x: f32, y: f32, z: f32, w: f32),
pub gl_gen_textures: unsafe extern "C" fn(n: gl::GLsizei, textures: *mut gl::GLuint),
pub gl_bind_texture: unsafe extern "C" fn(target: gl::GLenum, texture: gl::GLuint),
pub gl_tex_parameteri:
unsafe extern "C" fn(target: gl::GLenum, pname: gl::GLenum, param: gl::GLint),
pub gl_tex_image_2d: unsafe extern "C" fn(
target: gl::GLenum,
level: gl::GLint,
internalformat: gl::GLint,
width: gl::GLsizei,
height: gl::GLsizei,
border: gl::GLint,
format: gl::GLenum,
type_: gl::GLenum,
data: *const c_void_raw,
),
pub gl_active_texture: unsafe extern "C" fn(texture: gl::GLenum),
pub gl_gen_buffers: unsafe extern "C" fn(n: gl::GLsizei, buffers: *mut gl::GLuint),
pub gl_bind_buffer: unsafe extern "C" fn(target: gl::GLenum, buffer: gl::GLuint),
pub gl_buffer_data: unsafe extern "C" fn(
target: gl::GLenum,
size: isize,
data: *const c_void_raw,
usage: gl::GLenum,
),
pub gl_enable_vertex_attrib_array: unsafe extern "C" fn(index: gl::GLuint),
pub gl_vertex_attrib_pointer: unsafe extern "C" fn(
index: gl::GLuint,
size: gl::GLint,
type_: gl::GLenum,
normalized: gl::GLenum,
stride: gl::GLsizei,
pointer: *const c_void_raw,
),
pub gl_draw_elements: unsafe extern "C" fn(
mode: gl::GLenum,
count: gl::GLsizei,
type_: gl::GLenum,
indices: *const c_void_raw,
),
pub gl_get_error: unsafe extern "C" fn() -> gl::GLenum,
pub gl_delete_buffers: unsafe extern "C" fn(n: gl::GLsizei, buffers: *const gl::GLuint),
pub x_open_display: unsafe extern "C" fn(name: *const c_char) -> *mut x11::Display,
pub x_create_window: unsafe extern "C" fn(
disp: *mut x11::Display,
parent: x11::Window,
x: c_int,
y: c_int,
width: c_uint,
height: c_uint,
border_width: c_uint,
depth: c_int,
class: c_int,
visual: *mut c_void,
valuemask: c_long,
attributes: *mut x11::XSetWindowAttributes,
) -> x11::Window,
pub x_map_window: unsafe extern "C" fn(disp: *mut x11::Display, w: x11::Window) -> c_int,
pub x_store_name:
unsafe extern "C" fn(disp: *mut x11::Display, w: x11::Window, name: *const c_char) -> c_int,
pub x_flush: unsafe extern "C" fn(disp: *mut x11::Display) -> c_int,
}
impl Gl {
pub fn load() -> Result<Self> {
let lib_egl = unsafe { libloading::Library::new("libEGL.so.1")? };
let lib_gl = unsafe { libloading::Library::new("libGLESv2.so.2")? };
let lib_x11 = unsafe { libloading::Library::new("libX11.so.6")? };
unsafe {
Ok(Self {
egl_get_display: *lib_egl.get(b"eglGetDisplay\0")?,
egl_initialize: *lib_egl.get(b"eglInitialize\0")?,
egl_choose_config: *lib_egl.get(b"eglChooseConfig\0")?,
egl_create_window_surface: *lib_egl.get(b"eglCreateWindowSurface\0")?,
egl_create_context: *lib_egl.get(b"eglCreateContext\0")?,
egl_make_current: *lib_egl.get(b"eglMakeCurrent\0")?,
egl_swap_buffers: *lib_egl.get(b"eglSwapBuffers\0")?,
gl_clear: *lib_gl.get(b"glClear\0")?,
gl_clear_color: *lib_gl.get(b"glClearColor\0")?,
gl_enable: *lib_gl.get(b"glEnable\0")?,
gl_blend_func: *lib_gl.get(b"glBlendFunc\0")?,
gl_viewport: *lib_gl.get(b"glViewport\0")?,
gl_create_shader: *lib_gl.get(b"glCreateShader\0")?,
gl_shader_source: *lib_gl.get(b"glShaderSource\0")?,
gl_compile_shader: *lib_gl.get(b"glCompileShader\0")?,
gl_get_shaderiv: *lib_gl.get(b"glGetShaderiv\0")?,
gl_get_shader_info_log: *lib_gl.get(b"glGetShaderInfoLog\0")?,
gl_create_program: *lib_gl.get(b"glCreateProgram\0")?,
gl_attach_shader: *lib_gl.get(b"glAttachShader\0")?,
gl_link_program: *lib_gl.get(b"glLinkProgram\0")?,
gl_get_programiv: *lib_gl.get(b"glGetProgramiv\0")?,
gl_get_program_info_log: *lib_gl.get(b"glGetProgramInfoLog\0")?,
gl_use_program: *lib_gl.get(b"glUseProgram\0")?,
gl_get_attrib_location: *lib_gl.get(b"glGetAttribLocation\0")?,
gl_get_uniform_location: *lib_gl.get(b"glGetUniformLocation\0")?,
gl_uniform_matrix4fv: *lib_gl.get(b"glUniformMatrix4fv\0")?,
gl_uniform1i: *lib_gl.get(b"glUniform1i\0")?,
gl_uniform4f: *lib_gl.get(b"glUniform4f\0")?,
gl_gen_textures: *lib_gl.get(b"glGenTextures\0")?,
gl_bind_texture: *lib_gl.get(b"glBindTexture\0")?,
gl_tex_parameteri: *lib_gl.get(b"glTexParameteri\0")?,
gl_tex_image_2d: *lib_gl.get(b"glTexImage2D\0")?,
gl_active_texture: *lib_gl.get(b"glActiveTexture\0")?,
gl_gen_buffers: *lib_gl.get(b"glGenBuffers\0")?,
gl_bind_buffer: *lib_gl.get(b"glBindBuffer\0")?,
gl_buffer_data: *lib_gl.get(b"glBufferData\0")?,
gl_enable_vertex_attrib_array: *lib_gl.get(b"glEnableVertexAttribArray\0")?,
gl_vertex_attrib_pointer: *lib_gl.get(b"glVertexAttribPointer\0")?,
gl_draw_elements: *lib_gl.get(b"glDrawElements\0")?,
gl_get_error: *lib_gl.get(b"glGetError\0")?,
gl_delete_buffers: *lib_gl.get(b"glDeleteBuffers\0")?,
x_open_display: *lib_x11.get(b"XOpenDisplay\0")?,
x_create_window: *lib_x11.get(b"XCreateWindow\0")?,
x_map_window: *lib_x11.get(b"XMapWindow\0")?,
x_store_name: *lib_x11.get(b"XStoreName\0")?,
x_flush: *lib_x11.get(b"XFlush\0")?,
_lib_egl: lib_egl,
_lib_gl: lib_gl,
_lib_x11: lib_x11,
})
}
}
}
// ============ 渲染上下文 ============
pub struct RenderContext {
pub gl: Gl,
pub display: egl::EGLDisplay,
pub surface: egl::EGLSurface,
pub x_display: *mut x11::Display,
pub x_window: x11::Window,
pub program: GLuint,
pub attrib_position: GLint,
pub attrib_tex_coord: GLint,
pub uniform_matrix: GLint,
pub uniform_texture: GLint,
pub uniform_base_color: GLint,
pub uniform_multiply_color: GLint,
pub uniform_screen_color: GLint,
pub width: i32,
pub height: i32,
}
// 含裸指针,手动声明 Send仅在单渲染线程使用
unsafe impl Send for RenderContext {}
fn compile_shader(gl: &Gl, shader_type: GLenum, src: &str) -> Result<GLuint> {
unsafe {
let shader = (gl.gl_create_shader)(shader_type);
if shader == 0 {
return Err(anyhow!("glCreateShader 返回 0"));
}
let src_c = std::ffi::CString::new(src)?;
let src_ptr = src_c.as_ptr();
(gl.gl_shader_source)(shader, 1, &src_ptr, std::ptr::null());
(gl.gl_compile_shader)(shader);
let mut status = 0;
(gl.gl_get_shaderiv)(shader, gl::GL_COMPILE_STATUS, &mut status);
if status == 0 {
let mut log = vec![0i8; 1024];
(gl.gl_get_shader_info_log)(shader, 1024, std::ptr::null_mut(), log.as_mut_ptr());
let log_str = String::from_utf8_lossy(std::slice::from_raw_parts(
log.as_ptr() as *const u8,
1024,
));
return Err(anyhow!("着色器编译失败: {}", log_str));
}
Ok(shader)
}
}
impl RenderContext {
pub fn new_fullscreen(width: i32, height: i32) -> Result<Self> {
let gl = Gl::load()?;
// X11 窗口
let x_display = unsafe { (gl.x_open_display)(std::ptr::null()) };
if x_display.is_null() {
return Err(anyhow!("XOpenDisplay 失败"));
}
let mut attrs = x11::XSetWindowAttributes {
background_pixmap: 0,
background_pixel: 0,
border_pixmap: 0,
border_pixel: 0,
bit_gravity: 0,
win_gravity: 0,
backing_store: 0,
backing_planes: 0,
backing_pixel: 0,
save_under: 0,
event_mask: x11::ExposureMask | x11::StructureNotifyMask,
do_not_propagate_mask: 0,
override_redirect: 1,
colormap: 0,
cursor: 0,
};
let x_window = unsafe {
(gl.x_create_window)(
x_display,
0,
0,
0,
width as c_uint,
height as c_uint,
0,
24,
1,
std::ptr::null_mut(),
x11::CWOverrideRedirect | x11::CWEventMask,
&mut attrs,
)
};
if x_window == 0 {
return Err(anyhow!("XCreateWindow 失败"));
}
unsafe {
let title = std::ffi::CString::new("ShowenV2 Live2D")?;
(gl.x_store_name)(x_display, x_window, title.as_ptr());
(gl.x_map_window)(x_display, x_window);
(gl.x_flush)(x_display);
}
// EGL
let display = unsafe { (gl.egl_get_display)(egl::default_display()) };
if display.is_null() {
return Err(anyhow!("eglGetDisplay 失败"));
}
let mut major = 0;
let mut minor = 0;
if unsafe { (gl.egl_initialize)(display, &mut major, &mut minor) } == 0 {
return Err(anyhow!("eglInitialize 失败"));
}
println!("[Live2D] EGL {}.{}", major, minor);
let config_attrs = [
egl::EGL_RED_SIZE, 8,
egl::EGL_GREEN_SIZE, 8,
egl::EGL_BLUE_SIZE, 8,
egl::EGL_ALPHA_SIZE, 8,
egl::EGL_SURFACE_TYPE, egl::EGL_WINDOW_BIT,
egl::EGL_RENDERABLE_TYPE, egl::EGL_OPENGL_ES2_BIT,
egl::EGL_NONE,
];
let mut config: egl::EGLConfig = std::ptr::null_mut();
let mut num_config = 0;
if unsafe {
(gl.egl_choose_config)(
display,
config_attrs.as_ptr(),
&mut config,
1,
&mut num_config,
)
} == 0 || num_config == 0
{
return Err(anyhow!("eglChooseConfig 失败"));
}
let surface = unsafe {
(gl.egl_create_window_surface)(display, config, x_window as egl::NativeWindowType, std::ptr::null())
};
if surface.is_null() {
return Err(anyhow!("eglCreateWindowSurface 失败"));
}
let ctx_attrs = [egl::EGL_CONTEXT_CLIENT_VERSION, 2, egl::EGL_NONE];
let context = unsafe { (gl.egl_create_context)(display, config, std::ptr::null(), ctx_attrs.as_ptr()) };
if context.is_null() {
return Err(anyhow!("eglCreateContext 失败"));
}
if unsafe { (gl.egl_make_current)(display, surface, surface, context) } == 0 {
return Err(anyhow!("eglMakeCurrent 失败"));
}
unsafe {
(gl.gl_viewport)(0, 0, width, height);
(gl.gl_clear_color)(0.0, 0.0, 0.0, 1.0);
(gl.gl_enable)(gl::GL_BLEND);
(gl.gl_blend_func)(gl::GL_SRC_ALPHA, gl::GL_ONE_MINUS_SRC_ALPHA);
}
let vert = compile_shader(&gl, gl::GL_VERTEX_SHADER, VERT_SHADER_SRC)?;
let frag = compile_shader(&gl, gl::GL_FRAGMENT_SHADER, FRAG_SHADER_SRC)?;
let program = unsafe {
let p = (gl.gl_create_program)();
(gl.gl_attach_shader)(p, vert);
(gl.gl_attach_shader)(p, frag);
(gl.gl_link_program)(p);
let mut status = 0;
(gl.gl_get_programiv)(p, gl::GL_LINK_STATUS, &mut status);
if status == 0 {
let mut log = vec![0i8; 1024];
(gl.gl_get_program_info_log)(p, 1024, std::ptr::null_mut(), log.as_mut_ptr());
let log_str = String::from_utf8_lossy(std::slice::from_raw_parts(
log.as_ptr() as *const u8,
1024,
));
return Err(anyhow!("着色器链接失败: {}", log_str));
}
p
};
unsafe { (gl.gl_use_program)(program); }
let get = |name: &str| -> Result<GLint> {
let c = std::ffi::CString::new(name)?;
Ok(unsafe { (gl.gl_get_attrib_location)(program, c.as_ptr()) })
};
let getu = |name: &str| -> Result<GLint> {
let c = std::ffi::CString::new(name)?;
Ok(unsafe { (gl.gl_get_uniform_location)(program, c.as_ptr()) })
};
let attrib_position = get("a_position")?;
let attrib_tex_coord = get("a_texCoord")?;
let uniform_matrix = getu("u_matrix")?;
let uniform_texture = getu("s_texture0")?;
let uniform_base_color = getu("u_baseColor")?;
let uniform_multiply_color = getu("u_multiplyColor")?;
let uniform_screen_color = getu("u_screenColor")?;
println!(
"[Live2D] GL 程序就绪: program={} pos={} tex={} mat={} tex_u={} base={} mult={} scr={}",
program, attrib_position, attrib_tex_coord, uniform_matrix,
uniform_texture, uniform_base_color, uniform_multiply_color, uniform_screen_color
);
Ok(Self {
gl,
display,
surface,
x_display,
x_window,
program,
attrib_position,
attrib_tex_coord,
uniform_matrix,
uniform_texture,
uniform_base_color,
uniform_multiply_color,
uniform_screen_color,
width,
height,
})
}
pub fn create_texture(&self, img: &super::assets::TextureImage) -> GLuint {
unsafe {
let mut tex = 0;
(self.gl.gl_gen_textures)(1, &mut tex);
(self.gl.gl_bind_texture)(gl::GL_TEXTURE_2D, tex);
(self.gl.gl_tex_parameteri)(gl::GL_TEXTURE_2D, gl::GL_TEXTURE_MIN_FILTER, gl::GL_LINEAR as GLint);
(self.gl.gl_tex_parameteri)(gl::GL_TEXTURE_2D, gl::GL_TEXTURE_MAG_FILTER, gl::GL_LINEAR as GLint);
(self.gl.gl_tex_parameteri)(gl::GL_TEXTURE_2D, gl::GL_TEXTURE_WRAP_S, gl::GL_CLAMP_TO_EDGE as GLint);
(self.gl.gl_tex_parameteri)(gl::GL_TEXTURE_2D, gl::GL_TEXTURE_WRAP_T, gl::GL_CLAMP_TO_EDGE as GLint);
(self.gl.gl_tex_image_2d)(
gl::GL_TEXTURE_2D,
0,
gl::GL_RGBA as GLint,
img.width as GLsizei,
img.height as GLsizei,
0,
gl::GL_RGBA,
gl::GL_UNSIGNED_BYTE,
img.rgba.as_ptr() as *const c_void,
);
let err = (self.gl.gl_get_error)();
if err != gl::GL_NO_ERROR {
eprintln!("[Live2D] glTexImage2D 错误: 0x{:x}", err);
}
tex
}
}
pub fn swap_buffers(&self) {
unsafe {
(self.gl.egl_swap_buffers)(self.display, self.surface);
}
}
pub fn clear(&self) {
unsafe { (self.gl.gl_clear)(gl::GL_COLOR_BUFFER_BIT); }
}
}

19
src/plugins/live2d/mod.rs Normal file
View File

@@ -0,0 +1,19 @@
//! Live2D 原生渲染插件Rust + Cubism Core FFI + OpenGL ES 2.0
//!
//! 架构:
//! - `core_ffi`FFI 绑定 Cubism Core C APIlibLive2DCubismCore.so
//! - `model`moc3 加载、model 管理、参数/drawable 数据提取
//! - `assets`model3.json 解析、PNG 纹理加载
//! - `gl`EGL 上下文 + OpenGL ES 2.0 着色器/渲染
//! - `animation`:参数动画(眨眼、嘴部、待机呼吸)
//! - `renderer`:整合渲染循环
pub mod animation;
pub mod assets;
pub mod core_ffi;
pub mod gl;
pub mod model;
pub mod renderer;
mod plugin;
pub use plugin::Live2DPlugin;

309
src/plugins/live2d/model.rs Normal file
View File

@@ -0,0 +1,309 @@
//! Cubism Model 加载与管理
//!
//! 封装 moc3 读取 → revive → model 初始化 → 参数更新 → drawable 数据提取。
use super::core_ffi::{
alloc_aligned, AlignedBuffer, CubismCore, CsmVector2, CSM_ALIGNOF_MOC, CSM_ALIGNOF_MODEL,
};
use anyhow::{anyhow, Context, Result};
use std::path::Path;
/// 一个 drawable 的渲染所需数据快照
#[derive(Clone)]
pub struct DrawableSnapshot {
pub texture_index: i32,
pub draw_order: i32,
pub render_order: i32,
pub opacity: f32,
pub blend_mode: i32,
pub constant_flags: u8,
pub dynamic_flags: u8,
pub vertex_positions: Vec<[f32; 2]>,
pub vertex_uvs: Vec<[f32; 2]>,
pub indices: Vec<u16>,
pub multiply_color: [f32; 4],
pub screen_color: [f32; 4],
pub mask_counts: i32,
}
/// Canvas 信息
#[derive(Clone, Copy, Debug, Default)]
pub struct CanvasInfo {
pub size: [f32; 2],
pub origin: [f32; 2],
pub pixels_per_unit: f32,
}
/// 参数信息
pub struct ParameterInfo {
pub id: String,
pub min_value: f32,
pub max_value: f32,
pub default_value: f32,
}
/// 活跃的 Cubism Model
pub struct CubismModel {
/// Core 句柄(保持引用防止 .so 卸载)
_core: std::sync::Arc<CubismCore>,
/// moc3 原始数据(对齐 64
_moc_buf: AlignedBuffer,
/// moc 指针
moc: *mut std::ffi::c_void,
/// model 内存(对齐 16
_model_buf: AlignedBuffer,
/// model 指针
model: *mut std::ffi::c_void,
/// 参数数量
pub param_count: usize,
/// 参数 ID 列表(与 Core 内部顺序一致)
pub param_ids: Vec<String>,
/// 参数索引映射id → index
pub param_index: std::collections::HashMap<String, usize>,
/// drawable 数量
pub drawable_count: usize,
/// canvas 信息
pub canvas: CanvasInfo,
}
// model 指针可跨线程发送(渲染线程独占使用)
unsafe impl Send for CubismModel {}
impl CubismModel {
/// 从 moc3 文件加载 model
pub fn load(core: std::sync::Arc<CubismCore>, moc3_path: &Path) -> Result<Self> {
let moc_data = std::fs::read(moc3_path)
.with_context(|| format!("读取 moc3 失败: {}", moc3_path.display()))?;
// 分配 64 对齐内存
let mut moc_buf = alloc_aligned(moc_data.len(), CSM_ALIGNOF_MOC)?;
unsafe {
std::ptr::copy_nonoverlapping(moc_data.as_ptr(), moc_buf.as_ptr(), moc_data.len());
}
// 检查一致性
let valid = unsafe {
(core.csm_has_moc_consistency)(moc_buf.as_mut_void(), moc_data.len() as u32)
};
if valid != 1 {
return Err(anyhow!("moc3 一致性检查失败(文件损坏或版本不支持)"));
}
// Revive moc
let moc = unsafe {
(core.csm_revive_moc_in_place)(moc_buf.as_mut_void(), moc_data.len() as u32)
};
if moc.is_null() {
return Err(anyhow!("csmReviveMocInPlace 返回空指针"));
}
// 获取 model 所需大小
let model_size = unsafe { (core.csm_get_sizeof_model)(moc) };
if model_size == 0 {
return Err(anyhow!("csmGetSizeofModel 返回 0"));
}
// 分配 16 对齐内存
let mut model_buf = alloc_aligned(model_size as usize, CSM_ALIGNOF_MODEL)?;
let model = unsafe {
(core.csm_initialize_model_in_place)(
moc,
model_buf.as_mut_void(),
model_size,
)
};
if model.is_null() {
return Err(anyhow!("csmInitializeModelInPlace 返回空指针"));
}
// 读取参数信息
let param_count = unsafe { (core.csm_get_parameter_count)(model) } as usize;
let param_ids_ptr = unsafe { (core.csm_get_parameter_ids)(model) };
let mut param_ids = Vec::with_capacity(param_count);
let mut param_index = std::collections::HashMap::new();
for i in 0..param_count {
let id = unsafe {
super::core_ffi::cstr_ptr_to_string(*param_ids_ptr.add(i))
.unwrap_or_default()
};
param_index.insert(id.clone(), i);
param_ids.push(id);
}
// 读取 drawable 数量
let drawable_count = unsafe { (core.csm_get_drawable_count)(model) } as usize;
// 读取 canvas 信息
let mut size = CsmVector2::default();
let mut origin = CsmVector2::default();
let mut ppu = 0.0f32;
unsafe {
(core.csm_read_canvas_info)(model, &mut size, &mut origin, &mut ppu);
}
let canvas = CanvasInfo {
size: [size.x, size.y],
origin: [origin.x, origin.y],
pixels_per_unit: ppu,
};
println!(
"[Live2D] 模型加载成功: 参数={} drawable={} canvas={:?}x{:.0}ppu",
param_count, drawable_count, canvas.size, canvas.pixels_per_unit
);
Ok(Self {
_core: core,
_moc_buf: moc_buf,
moc,
_model_buf: model_buf,
model,
param_count,
param_ids,
param_index,
drawable_count,
canvas,
})
}
/// 获取参数最小值
pub fn param_min_values(&self, core: &CubismCore) -> Vec<f32> {
unsafe {
let p = (core.csm_get_parameter_minimum_values)(self.model);
std::slice::from_raw_parts(p, self.param_count).to_vec()
}
}
/// 获取参数最大值
pub fn param_max_values(&self, core: &CubismCore) -> Vec<f32> {
unsafe {
let p = (core.csm_get_parameter_maximum_values)(self.model);
std::slice::from_raw_parts(p, self.param_count).to_vec()
}
}
/// 获取参数默认值
pub fn param_default_values(&self, core: &CubismCore) -> Vec<f32> {
unsafe {
let p = (core.csm_get_parameter_default_values)(self.model);
std::slice::from_raw_parts(p, self.param_count).to_vec()
}
}
/// 获取参数值缓冲区(可读写)
pub fn param_values_mut(&self, core: &CubismCore) -> &mut [f32] {
unsafe {
let p = (core.csm_get_parameter_values)(self.model);
std::slice::from_raw_parts_mut(p, self.param_count)
}
}
/// 按参数 ID 设置值
pub fn set_param(&self, core: &CubismCore, id: &str, value: f32) -> bool {
if let Some(&idx) = self.param_index.get(id) {
let slice = self.param_values_mut(core);
slice[idx] = value;
true
} else {
false
}
}
/// 按参数 ID 获取值
pub fn get_param(&self, core: &CubismCore, id: &str) -> Option<f32> {
let idx = *self.param_index.get(id)?;
let slice = self.param_values_mut(core);
Some(slice[idx])
}
/// 将所有参数重置为默认值
pub fn reset_to_default(&self, core: &CubismCore) {
let defaults = self.param_default_values(core);
let slice = self.param_values_mut(core);
for (i, v) in defaults.into_iter().enumerate() {
slice[i] = v;
}
}
/// 更新 model应用参数变化重新计算顶点/不透明度等)
pub fn update(&self, core: &CubismCore) {
unsafe { (core.csm_update_model)(self.model) };
}
/// 重置动态标志
pub fn reset_dynamic_flags(&self, core: &CubismCore) {
unsafe { (core.csm_reset_drawable_dynamic_flags)(self.model) };
}
/// 获取所有 drawable 的渲染数据快照(按 render order 排序)
pub fn drawable_snapshots(&self, core: &CubismCore) -> Vec<DrawableSnapshot> {
let count = self.drawable_count;
unsafe {
let render_orders = (core.csm_get_render_orders)(self.model);
let texture_indices = (core.csm_get_drawable_texture_indices)(self.model);
let draw_orders = (core.csm_get_drawable_draw_orders)(self.model);
let opacities = (core.csm_get_drawable_opacities)(self.model);
let blend_modes = (core.csm_get_drawable_blend_modes)(self.model);
let const_flags = (core.csm_get_drawable_constant_flags)(self.model);
let dyn_flags = (core.csm_get_drawable_dynamic_flags)(self.model);
let mask_counts = (core.csm_get_drawable_mask_counts)(self.model);
let vertex_counts = (core.csm_get_drawable_vertex_counts)(self.model);
let vertex_positions = (core.csm_get_drawable_vertex_positions)(self.model);
let vertex_uvs = (core.csm_get_drawable_vertex_uvs)(self.model);
let index_counts = (core.csm_get_drawable_index_counts)(self.model);
let indices = (core.csm_get_drawable_indices)(self.model);
let multiply_colors = (core.csm_get_drawable_multiply_colors)(self.model);
let screen_colors = (core.csm_get_drawable_screen_colors)(self.model);
// 先收集每个 drawable 的原始索引
let mut items: Vec<(usize, i32)> = (0..count)
.map(|i| (i, *render_orders.add(i)))
.collect();
// 按 render_order 升序排列
items.sort_by_key(|&(_, ro)| ro);
items
.into_iter()
.map(|(i, ro)| {
let vc = *vertex_counts.add(i) as usize;
let ic = *index_counts.add(i) as usize;
let vp = *vertex_positions.add(i);
let vu = *vertex_uvs.add(i);
let idx = *indices.add(i);
let mut verts = Vec::with_capacity(vc);
let mut uvs = Vec::with_capacity(vc);
for v in 0..vc {
let p = *vp.add(v);
let u = *vu.add(v);
verts.push([p.x, p.y]);
uvs.push([u.x, u.y]);
}
let mut idx_vec = Vec::with_capacity(ic);
for k in 0..ic {
idx_vec.push(*idx.add(k));
}
let mc = *multiply_colors.add(i);
let sc = *screen_colors.add(i);
DrawableSnapshot {
texture_index: *texture_indices.add(i),
draw_order: *draw_orders.add(i),
render_order: ro,
opacity: *opacities.add(i),
blend_mode: *blend_modes.add(i),
constant_flags: *const_flags.add(i),
dynamic_flags: *dyn_flags.add(i),
vertex_positions: verts,
vertex_uvs: uvs,
indices: idx_vec,
multiply_color: [mc.x, mc.y, mc.z, mc.w],
screen_color: [sc.x, sc.y, sc.z, sc.w],
mask_counts: *mask_counts.add(i),
}
})
.collect()
}
}
}

View File

@@ -0,0 +1,220 @@
//! Live2DPlugin — Live2D 原生渲染插件
//!
//! 接管 Live2D 模式下的设备端渲染,替代 Firefox kiosk 方案。
//! 监听 ConfigReloaded 消息,当 render_type=Live2d 时启动原生渲染循环。
use crate::core::config::RenderType;
use crate::core::message::{Destination, Envelope, Message};
use crate::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use super::renderer::Live2DRenderer;
pub struct Live2DPlugin {
ctx: Option<PluginContext>,
/// 渲染器句柄(运行在独立线程)
renderer: Option<Arc<std::sync::Mutex<Live2DRenderer>>>,
worker: Option<JoinHandle<()>>,
/// 渲染停止信号
stop_flag: Arc<AtomicBool>,
}
impl Live2DPlugin {
pub fn new() -> Self {
Self {
ctx: None,
renderer: None,
worker: None,
stop_flag: Arc::new(AtomicBool::new(false)),
}
}
}
impl Default for Live2DPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for Live2DPlugin {
fn id(&self) -> &str {
"live2d"
}
fn info(&self) -> PluginInfo {
PluginInfo {
name: "Live2D Renderer".to_string(),
version: "0.1.0".to_string(),
description: "Live2D 原生渲染Cubism Core FFI + OpenGL ES 2.0".to_string(),
platform: Platform::Linux,
}
}
fn dependencies(&self) -> Vec<String> {
vec![]
}
fn init(&mut self, ctx: PluginContext) -> Result<()> {
self.ctx = Some(ctx);
Ok(())
}
fn start(&mut self) -> Result<()> {
let ctx = self
.ctx
.as_ref()
.context("live2d plugin context is not initialized")?;
ctx.tx.send(Envelope {
from: self.id().to_string(),
to: Destination::Manager,
message: Message::PluginReady(self.id().to_string()),
})?;
Ok(())
}
fn handle_message(&mut self, msg: Message) -> Result<()> {
match msg {
Message::ConfigReloaded(config) => {
if config.character.render_type == RenderType::Live2d {
self.start_live2d(&config)?;
} else {
self.stop_live2d();
}
}
Message::Shutdown => {
self.stop_live2d();
}
_ => {}
}
Ok(())
}
fn stop(&mut self) -> Result<()> {
self.stop_live2d();
Ok(())
}
}
impl Live2DPlugin {
fn start_live2d(&mut self, config: &crate::core::config::AppConfig) -> Result<()> {
// 先停止旧的渲染
self.stop_live2d();
let model_rel = config
.character
.live2d_model
.clone()
.context("未配置 live2d_model")?;
// Core .so 路径(相对项目根目录或绝对路径)
let core_so = find_core_so()?;
// live2d 资源根目录
let live2d_base = find_live2d_base(config)?;
let width = config.display.render_width as i32;
let height = config.display.render_height as i32;
let stop_flag = Arc::clone(&self.stop_flag);
stop_flag.store(false, Ordering::SeqCst);
let tx = self
.ctx
.as_ref()
.context("no ctx")?
.tx
.clone();
let renderer = Arc::new(std::sync::Mutex::new(
Live2DRenderer::new(&core_so, &live2d_base, &model_rel, width, height)?,
));
self.renderer = Some(Arc::clone(&renderer));
let handle = std::thread::spawn(move || {
// 渲染循环
loop {
if stop_flag.load(Ordering::SeqCst) {
break;
}
// 检查 talking 状态(从 HTTP 插件共享的状态读取,这里通过轮询 config 实现)
// 简化:直接渲染
let render_result = {
let mut r = renderer.lock().unwrap();
r.render_frame()
};
if let Err(e) = render_result {
eprintln!("[Live2DPlugin] 渲染失败: {e}");
let _ = tx.send(Envelope {
from: "live2d".to_string(),
to: Destination::Manager,
message: Message::StateChanged {
old_state: "live2d_running".into(),
new_state: "live2d_error".into(),
},
});
break;
}
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
self.worker = Some(handle);
println!("[Live2DPlugin] Live2D 原生渲染已启动: {model_rel}");
Ok(())
}
fn stop_live2d(&mut self) {
self.stop_flag.store(true, Ordering::SeqCst);
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
self.renderer = None;
}
}
/// 查找 libLive2DCubismCore.so
fn find_core_so() -> Result<String> {
// 候选路径优先级:
// 1. 环境变量 SHOWEN_LIVE2D_CORE_SO
// 2. 项目根 extern/libLive2DCubismCore.so运行时随二进制一起部署
// 3. /usr/lib/libLive2DCubismCore.so
// 4. /home/showen/Showen/ShowenV2/extern/libLive2DCubismCore.so
if let Ok(p) = std::env::var("SHOWEN_LIVE2D_CORE_SO") {
if std::path::Path::new(&p).exists() {
return Ok(p);
}
}
let candidates = [
"extern/libLive2DCubismCore.so",
"/usr/lib/libLive2DCubismCore.so",
"/usr/local/lib/libLive2DCubismCore.so",
"/home/showen/Showen/ShowenV2/extern/libLive2DCubismCore.so",
];
for c in &candidates {
if std::path::Path::new(c).exists() {
return Ok(c.to_string());
}
}
anyhow::bail!("找不到 libLive2DCubismCore.so请设置环境变量 SHOWEN_LIVE2D_CORE_SO 或部署到 extern/")
}
/// 查找 live2d 资源根目录
fn find_live2d_base(config: &crate::core::config::AppConfig) -> Result<PathBuf> {
let candidates = [
PathBuf::from("configs/live2d"),
PathBuf::from("/home/showen/Showen/ShowenV2/configs/live2d"),
std::env::current_dir()?.join("configs/live2d"),
];
for c in &candidates {
if c.exists() {
return Ok(c.clone());
}
}
anyhow::bail!("找不到 configs/live2d 目录")
}

View File

@@ -0,0 +1,262 @@
//! Live2D 渲染器:整å<C2B4>ˆ Core + Model + GL + Animation
use super::animation::AnimationState;
use super::assets::{self, Model3Json, TextureImage};
use super::core_ffi::{self, CubismCore};
use super::gl::{self as gl_api, RenderContext};
use super::model::{CubismModel, DrawableSnapshot};
use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct Live2DRenderer {
pub core: Arc<CubismCore>,
pub model: CubismModel,
pub model3: Model3Json,
pub textures: Vec<TextureImage>,
pub gl_ctx: RenderContext,
pub texture_ids: Vec<u32>,
pub animation: AnimationState,
pub model_matrix: [f32; 16],
pub running: bool,
}
impl Live2DRenderer {
pub fn new(
core_so_path: &str,
live2d_base_dir: &Path,
model3_json_rel: &str,
width: i32,
height: i32,
) -> Result<Self> {
let core = Arc::new(CubismCore::load(core_so_path)?);
println!("[Live2D] Core 版本: {}", core.version_string());
let paths = assets::resolve_asset_paths(live2d_base_dir, model3_json_rel)?;
let model3 = Model3Json::from_path(&paths.model3_json)?;
println!(
"[Live2D] model3.json: 版本={} moc={} 纹ç<C2B9>†æ•?{}",
model3.version,
model3.file_references.moc,
model3.file_references.textures.len()
);
let model = CubismModel::load(Arc::clone(&core), &paths.moc3)?;
let textures = assets::load_all_textures(&paths.model3_dir, &model3.file_references)?;
let gl_ctx = RenderContext::new_fullscreen(width, height)?;
let texture_ids: Vec<u32> = textures.iter().map(|t| gl_ctx.create_texture(t)).collect();
println!("[Live2D] 已上ä¼?{} 个纹ç<C2B9>†åˆ° GPU", texture_ids.len());
let canvas = model.canvas;
let model_matrix = compute_model_matrix(canvas, width, height);
model.reset_to_default(&core);
Ok(Self {
core,
model,
model3,
textures,
gl_ctx,
texture_ids,
animation: AnimationState::default(),
model_matrix,
running: false,
})
}
pub fn set_talking(&mut self, talking: bool) {
self.animation.set_talking(talking);
}
pub fn render_frame(&mut self) -> Result<()> {
let now = Instant::now();
let dt = 1.0 / 60.0;
self.animation.update(&self.core, &self.model, now, dt);
self.model.update(&self.core);
let snapshots = self.model.drawable_snapshots(&self.core);
unsafe {
let gl = &self.gl_ctx.gl;
(gl.gl_clear_color)(0.0, 0.0, 0.0, 1.0);
(gl.gl_clear)(gl_api::GL_COLOR_BUFFER_BIT);
(gl.gl_enable)(gl_api::GL_BLEND);
(gl.gl_use_program)(self.gl_ctx.program);
(gl.gl_uniform_matrix4fv)(
self.gl_ctx.uniform_matrix,
1,
gl_api::GL_FALSE,
self.model_matrix.as_ptr(),
);
}
for snap in &snapshots {
if snap.opacity <= 0.001 {
continue;
}
self.draw_drawable(snap);
}
self.model.reset_dynamic_flags(&self.core);
unsafe {
(self.gl_ctx.gl.egl_swap_buffers)(self.gl_ctx.display, self.gl_ctx.surface);
}
Ok(())
}
fn draw_drawable(&self, snap: &DrawableSnapshot) {
let gl = &self.gl_ctx.gl;
unsafe {
// 纹ç<C2B9>
let tex_idx = snap.texture_index as usize;
if tex_idx < self.texture_ids.len() {
(gl.gl_active_texture)(gl_api::GL_TEXTURE0);
(gl.gl_bind_texture)(gl_api::GL_TEXTURE_2D, self.texture_ids[tex_idx]);
(gl.gl_uniform1i)(self.gl_ctx.uniform_texture, 0);
}
// base color(ä¸<C3A4>é€<C3A9>明度乘åˆ?alphaï¼? (gl.gl_uniform4f)(self.gl_ctx.uniform_base_color, 1.0, 1.0, 1.0, snap.opacity);
// multiply/screen color
let mc = snap.multiply_color;
(gl.gl_uniform4f)(self.gl_ctx.uniform_multiply_color, mc[0], mc[1], mc[2], mc[3]);
let sc = snap.screen_color;
(gl.gl_uniform4f)(self.gl_ctx.uniform_screen_color, sc[0], sc[1], sc[2], sc[3]);
// æ··å<C2B7>ˆæ¨¡å¼<C3A5>
match snap.blend_mode {
core_ffi::blend_mode::NORMAL => {
(gl.gl_blend_func)(gl_api::GL_SRC_ALPHA, gl_api::GL_ONE_MINUS_SRC_ALPHA);
}
core_ffi::blend_mode::ADDITIVE => {
(gl.gl_blend_func)(gl_api::GL_SRC_ALPHA, gl_api::GL_ONE);
}
core_ffi::blend_mode::MULTIPLICATIVE => {
(gl.gl_blend_func)(gl_api::GL_ZERO, gl_api::GL_SRC_COLOR);
}
_ => {
(gl.gl_blend_func)(gl_api::GL_SRC_ALPHA, gl_api::GL_ONE_MINUS_SRC_ALPHA);
}
}
// é¡¶ç¹æ•°æ<C2B0>®ï¼šposition(xy) + texCoord(uv) 交错
let vc = snap.vertex_positions.len();
let mut vertex_data: Vec<f32> = Vec::with_capacity(vc * 4);
for i in 0..vc {
vertex_data.push(snap.vertex_positions[i][0]);
vertex_data.push(snap.vertex_positions[i][1]);
vertex_data.push(snap.vertex_uvs[i][0]);
vertex_data.push(snap.vertex_uvs[i][1]);
}
let mut vbo = 0u32;
(gl.gl_gen_buffers)(1, &mut vbo);
(gl.gl_bind_buffer)(gl_api::GL_ARRAY_BUFFER, vbo);
(gl.gl_buffer_data)(
gl_api::GL_ARRAY_BUFFER,
(vertex_data.len() * 4) as isize,
vertex_data.as_ptr() as *const std::ffi::c_void,
gl_api::GL_STATIC_DRAW,
);
let mut ibo = 0u32;
(gl.gl_gen_buffers)(1, &mut ibo);
(gl.gl_bind_buffer)(gl_api::GL_ELEMENT_ARRAY_BUFFER, ibo);
(gl.gl_buffer_data)(
gl_api::GL_ELEMENT_ARRAY_BUFFER,
(snap.indices.len() * 2) as isize,
snap.indices.as_ptr() as *const std::ffi::c_void,
gl_api::GL_STATIC_DRAW,
);
let stride = 4 * 4;
if self.gl_ctx.attrib_position >= 0 {
(gl.gl_enable_vertex_attrib_array)(self.gl_ctx.attrib_position as u32);
(gl.gl_vertex_attrib_pointer)(
self.gl_ctx.attrib_position as u32,
2,
gl_api::GL_FLOAT,
gl_api::GL_FALSE,
stride,
std::ptr::null(),
);
}
if self.gl_ctx.attrib_tex_coord >= 0 {
(gl.gl_enable_vertex_attrib_array)(self.gl_ctx.attrib_tex_coord as u32);
(gl.gl_vertex_attrib_pointer)(
self.gl_ctx.attrib_tex_coord as u32,
2,
gl_api::GL_FLOAT,
gl_api::GL_FALSE,
stride,
(2 * 4) as *const std::ffi::c_void,
);
}
(gl.gl_draw_elements)(
gl_api::GL_TRIANGLES,
snap.indices.len() as i32,
gl_api::GL_UNSIGNED_SHORT,
std::ptr::null(),
);
(gl.gl_delete_buffers)(1, &vbo);
(gl.gl_delete_buffers)(1, &ibo);
}
}
pub fn run_loop(&mut self) -> Result<()> {
self.running = true;
let frame_duration = Duration::from_millis(16);
while self.running {
let start = Instant::now();
if let Err(e) = self.render_frame() {
eprintln!("[Live2D] 渲染帧失� {e}");
break;
}
let elapsed = start.elapsed();
if elapsed < frame_duration {
std::thread::sleep(frame_duration - elapsed);
}
}
Ok(())
}
pub fn stop(&mut self) {
self.running = false;
}
}
/// 计算 model â†?NDC å<>˜æ<CB9C>¢çŸ©é˜µï¼ˆåˆ—优先 4x4ï¼?///
/// Cubism å<><C3A5>标:原ç¹åœ¨ canvas 中心,Y è½´å<C2B4>上,å<C592>•ä½<C3A4>为åƒ<C3A5>ç´ ã€?/// 需è¦<C3A8>等比缩放使模åžå®Œæ•´æ˜¾ç¤ºåœ¨çª—å<E28094>£å†…ã€?fn compute_model_matrix(
canvas: super::model::CanvasInfo,
_width: i32,
_height: i32,
) -> [f32; 16] {
let canvas_w = canvas.size[0].max(1.0);
let canvas_h = canvas.size[1].max(1.0);
// 计算缩放:让模åžç­‰æ¯”é€åº”窗å<E28094>£
let scale_x = 2.0 / canvas_w;
let scale_y = 2.0 / canvas_h;
let scale = scale_x.min(scale_y);
// 列优先矩阵:
// [ scale 0 0 0 ]
// [ 0 scale 0 0 ]
// [ 0 0 1 0 ]
// [ 0 0 0 1 ]
[
scale, 0.0, 0.0, 0.0,
0.0, scale, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
]
}

View File

@@ -2,6 +2,7 @@ pub mod ai;
pub mod ble;
pub mod device;
pub mod http;
pub mod live2d;
pub mod screen;
pub mod video;
pub mod wifi;

View File

@@ -195,6 +195,33 @@ impl Plugin for VideoPlugin {
self.publish_status();
}
Message::ConfigReloaded(config) => {
// Live2D 模式:停止视频播放,由 Live2DPlugin 接管原生渲染
if config.character.render_type == crate::core::config::RenderType::Live2d {
// 停止视频播放
if let Some(old) = self.processor.take() {
if let Ok(mut old) = old.lock() {
let _ = old.stop();
}
}
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
// 杀掉残留的 Firefox如旧方案遗留
let _ = std::process::Command::new("/bin/bash")
.arg("-c")
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
.output();
println!("[VideoPlugin] Live2D 模式:视频已停止,由 Live2DPlugin 接管渲染");
self.publish_status();
return Ok(());
}
// 切回视频模式:杀掉残留 Firefox
let _ = std::process::Command::new("/bin/bash")
.arg("-c")
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
.output();
let processor = Arc::new(Mutex::new(VideoProcessor::new(config)?));
if let Some(old) = self.processor.replace(Arc::clone(&processor)) {
if let Ok(mut old) = old.lock() {
@@ -240,6 +267,12 @@ impl Plugin for VideoPlugin {
let _ = handle.join();
}
// 清理残留 Firefox兼容旧方案
let _ = std::process::Command::new("/bin/bash")
.arg("-c")
.arg("kill -9 $(pgrep firefox-esr) 2>/dev/null")
.output();
self.publish_status();
Ok(())
}