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:
71
configs/live2d_anime.json
Normal file
71
configs/live2d_anime.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"display": {
|
||||
"fullscreen": true,
|
||||
"window_title": "Showen - Live2D Anime",
|
||||
"rotation": 0,
|
||||
"flip_horizontal": false,
|
||||
"flip_vertical": false,
|
||||
"offset_x": 0,
|
||||
"offset_y": 0,
|
||||
"prevent_screen_lock": true,
|
||||
"render_width": 800,
|
||||
"render_height": 800,
|
||||
"output_width": null,
|
||||
"output_height": null,
|
||||
"scale_mode": "stretch",
|
||||
"allow_upscale": true,
|
||||
"perspective_correction": {
|
||||
"enabled": false,
|
||||
"points": [[0, 0], [800, 0], [800, 800], [0, 800]]
|
||||
},
|
||||
"chroma_key": {
|
||||
"enabled": false,
|
||||
"hsv_min": [0, 0, 200],
|
||||
"hsv_max": [180, 30, 255]
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
"auto_start": true,
|
||||
"loop": true,
|
||||
"volume": 0.8,
|
||||
"fps": 30
|
||||
},
|
||||
"playlist": [
|
||||
{
|
||||
"id": "live2d_idle",
|
||||
"path": "../../宠物嵌入式开发视频2/1.动画0.mp4",
|
||||
"loop_count": 0,
|
||||
"next_state": "idle"
|
||||
}
|
||||
],
|
||||
"states": {
|
||||
"initial": "idle",
|
||||
"idle": {
|
||||
"video": "live2d_idle",
|
||||
"next": "idle",
|
||||
"loop": true
|
||||
}
|
||||
},
|
||||
"triggers": {},
|
||||
"remote_control": {
|
||||
"enabled": true,
|
||||
"host": "0.0.0.0",
|
||||
"port": 5000
|
||||
},
|
||||
"character": {
|
||||
"name": "小雅",
|
||||
"character_type": "live2d",
|
||||
"render_type": "live2d",
|
||||
"persona_prompt": "你是一个温柔的二次元少女,名叫小雅。说话活泼可爱,偶尔用语气词'呢''哦''嘛',每次回复不超过两句话。喜欢聊日常和动漫话题。",
|
||||
"max_tokens": 128,
|
||||
"talk_state": "talk",
|
||||
"live2d_model": "haru/index.model3.json"
|
||||
},
|
||||
"ai": {
|
||||
"backend": "local",
|
||||
"whisper_lib_dir": "/home/showen/ai_spike/whisper.cpp/build/bin",
|
||||
"piper_lib_dir": "/home/showen/ai_spike/piper/piper",
|
||||
"piper_config": "/home/showen/ai_spike/piper/zh_CN-huayan-medium.onnx.json",
|
||||
"espeak_data_dir": "/home/showen/ai_spike/piper/piper/espeak-ng-data"
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ header{display:flex;align-items:center;justify-content:space-between;gap:12px;pa
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -48,13 +48,13 @@ function updateDownloadModal(info){var meta=$('download-meta'),link=$('download-
|
||||
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"><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}
|
||||
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'&¤tChar.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){var c=$('live2dContainer');if(!c)return;var ex=c.querySelector('.live2d-talking');if(talking&&!ex){var b=document.createElement('div');b.className='live2d-talking';b.textContent='说话中';c.appendChild(b)}else if(!talking&&ex)ex.remove()}
|
||||
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='';var cfgRes=await fetch('/api/config');var cfg=await cfgRes.json();var char=cfg.character||{};var strat=charStrategies[char.character_type||'pet']||charStrategies.pet;(data.configs||[]).forEach(function(c){var isActive=c===(data.active||'');var card=document.createElement('div');card.className='char-card'+(isActive?' active':'');var 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=function(){switchCharacter(c)};grid.appendChild(card)})}catch(e){$('charGrid').innerHTML='<div style="color:var(--red)">加载失败</div>'}}
|
||||
@@ -70,5 +70,10 @@ async function sendAudioBlob(blob,mimeType){if(chatBusy)return;chatBusy=true;$('
|
||||
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();
|
||||
|
||||
@@ -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