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 子进程
This commit is contained in:
@@ -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)))
|
||||
@@ -202,7 +203,11 @@ fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> +
|
||||
.into_response(),
|
||||
}
|
||||
});
|
||||
index.or(index_html).or(chat).or(chat_js)
|
||||
let live2d_display = warp::path("live2d-display.html")
|
||||
.and(warp::path::end())
|
||||
.and(warp::get())
|
||||
.map(|| warp::reply::html(include_str!("live2d_display.html")));
|
||||
index.or(index_html).or(chat).or(chat_js).or(live2d_display)
|
||||
}
|
||||
|
||||
fn ws_route(
|
||||
@@ -2155,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 {
|
||||
@@ -2164,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))
|
||||
@@ -2346,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 — 列出所有模型
|
||||
|
||||
Reference in New Issue
Block a user