diff --git a/src/plugins/http/live2d_display.html b/src/plugins/http/live2d_display.html new file mode 100644 index 0000000..4b01c75 --- /dev/null +++ b/src/plugins/http/live2d_display.html @@ -0,0 +1,72 @@ + + + + +Live2D Display + + + + +
加载中...
+ + + + + + + diff --git a/src/plugins/http/mod.rs b/src/plugins/http/mod.rs index 5e1da4b..3f3cbf8 100644 --- a/src/plugins/http/mod.rs +++ b/src/plugins/http/mod.rs @@ -52,6 +52,8 @@ pub(crate) struct HttpState { ai_pipeline: Mutex>>, /// AI 模型管理器共享句柄(HTTP 模型管理 API 调用) ai_models: Mutex>>>, + /// 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; diff --git a/src/plugins/http/routes.rs b/src/plugins/http/routes.rs index aa86bdd..20f4503 100644 --- a/src/plugins/http/routes.rs +++ b/src/plugins/http/routes.rs @@ -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 + .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, +) -> impl Filter + Clone { + warp::path!("api" / "live2d" / "talking") + .and(warp::get()) + .and(with_state(state)) + .and_then(|state: Arc| async move { + Ok::<_, Infallible>(json_response( + StatusCode::OK, + &serde_json::json!({ "talking": state.live2d_talking() }), + )) + }) +} + // ── AI 模型管理 API (M2.1) ── /// GET /api/models — 列出所有模型 diff --git a/src/plugins/video/mod.rs b/src/plugins/video/mod.rs index 9c6f032..0c4f745 100644 --- a/src/plugins/video/mod.rs +++ b/src/plugins/video/mod.rs @@ -18,6 +18,8 @@ pub struct VideoPlugin { ctx: Option, processor: Option>>, worker: Option>, + /// Live2D 模式下运行的 Firefox kiosk 子进程 + live2d_child: Option, } impl VideoPlugin { @@ -26,6 +28,7 @@ impl VideoPlugin { ctx: None, processor: None, worker: None, + live2d_child: None, } } @@ -195,6 +198,53 @@ impl Plugin for VideoPlugin { self.publish_status(); } Message::ConfigReloaded(config) => { + // Live2D 模式:停止视频播放,启动 Firefox kiosk 全屏渲染 Live2D 模型 + 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(如有) + if let Some(mut child) = self.live2d_child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + // 启动 Firefox kiosk 模式全屏显示 Live2D + let port = config.remote_control.port; + let url = format!("http://localhost:{port}/live2d-display.html"); + match std::process::Command::new("firefox") + .arg("--kiosk") + .arg("--width").arg(config.display.render_width.to_string()) + .arg("--height").arg(config.display.render_height.to_string()) + .arg(&url) + .env("DISPLAY", ":0") + .env("MOZ_DISABLE_AUTOPLAY", "1") + .spawn() + { + Ok(child) => { + self.live2d_child = Some(child); + println!("[VideoPlugin] Live2D 模式:Firefox kiosk 已启动 ({url})"); + } + Err(e) => { + eprintln!("[VideoPlugin] 启动 Firefox 失败: {e}"); + } + } + self.publish_status(); + return Ok(()); + } + + // 切回视频模式:杀掉 Firefox kiosk + if let Some(mut child) = self.live2d_child.take() { + let _ = child.kill(); + let _ = child.wait(); + println!("[VideoPlugin] 切回视频模式:Firefox kiosk 已关闭"); + } + 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 +290,12 @@ impl Plugin for VideoPlugin { let _ = handle.join(); } + // 关闭 Live2D Firefox kiosk + if let Some(mut child) = self.live2d_child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + self.publish_status(); Ok(()) }