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)
This commit is contained in:
@@ -159,6 +159,7 @@ pub(crate) fn build_routes(
|
||||
|
||||
root_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(
|
||||
@@ -248,6 +249,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 {
|
||||
|
||||
Reference in New Issue
Block a user