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:
2026-07-07 13:24:57 +08:00
parent af5b975375
commit 77d788d7a5
4 changed files with 180 additions and 1 deletions

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)))
@@ -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 — 列出所有模型