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:
2026-07-07 11:56:29 +08:00
parent 1ab77df85d
commit 39fa2a135a
4 changed files with 175 additions and 2 deletions

71
configs/live2d_anime.json Normal file
View 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"
}
}

View File

@@ -42,6 +42,11 @@ header{display:flex;align-items:center;justify-content:space-between;gap:12px;pa
</div> </div>
<div class="toast" id="toast"></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> <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> <script src="chat.js"></script>
</body> </body>
</html> </html>

View File

@@ -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 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')} 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()}); 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'&&currentChar.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()} 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'&&currentChar.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 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 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 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 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} 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()}}); $('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>'}} 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)} 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 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'})} 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(); connectWS();refreshStatus();loadConfig();

View File

@@ -159,6 +159,7 @@ pub(crate) fn build_routes(
root_route() root_route()
.or(download_route(Arc::clone(&state))) .or(download_route(Arc::clone(&state)))
.or(live2d_static_route(Arc::clone(&state)))
.or(ws_route(tx.clone(), Arc::clone(&state))) .or(ws_route(tx.clone(), Arc::clone(&state)))
.or(api) .or(api)
.with( .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( fn status_route(
state: Arc<HttpState>, state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone { ) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {