Files
EzVibe/ui/live2d_view.py
e2hang 798e5c2f7d checkpoint: segfault root cause analysis + Live2D stability fixes
- _refresh_messages: widget pool reuse to avoid layout cascade → QWebEngineView crash
- viewer.html: QWebChannel bridge, texture resize, burst render, gl.finish
- live2d_view.py: debug checkpoints, playMotion/setExpression render-on-demand
- main.py: Chromium flags --no-sandbox --in-process-gpu --disable-gpu-rasterization --ignore-gpu-blocklist
- scheduler.py: test_reminder re-enabled
- docs: complete root cause analysis
2026-05-23 13:33:58 +08:00

315 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ui/live2d_view.py
=================
EzVibe Live2D 渲染组件 — 基于 QWebEngineView + 本地 HTML + Cubism SDK JS。
功能
----
- 通过 QWebEngineView 加载本地 HTML内嵌 Live2D Cubism SDK JS
- 加载 .moc3 模型并渲染 Live2D 角色miku
- 支持播放动画playMotion、切换表情setExpression
- 通过 QtWebChannel 实现 Python → JS 双向通信
- 支持 Python 调用 JS 方法,以及 JS 回调 Python点击事件等
用法
----
from ui.live2d_view import Live2DWidget
widget = Live2DWidget(parent, assets_dir="assets/live2d/miku")
widget.playMotion("idle")
widget.setExpression("happy")
widget.resize(200, 200)
"""
from __future__ import annotations
import pathlib
import threading
from http.server import HTTPServer, SimpleHTTPRequestHandler
# ── Null placeholder ─────────────────────────────────────────────────
class _NullLive2DView:
"""Dummy when PyQt5 WebEngine not available."""
def __init__(self, *args, **kwargs): pass
def playMotion(self, *a, **kw): pass
def setExpression(self, *a, **kw): pass
def resize(self, *a, **kw): pass
def show(self): pass
def cleanup(self): pass
# ── Live2DWidget factory ──────────────────────────────────────────────
def Live2DWidget(parent=None, assets_dir: str | pathlib.Path | None = None):
"""
工厂函数:返回真实 Live2DWidget 或 DummyWebEngine 不可用时)。
注意:必须在校入 QApplication 后调用!
"""
impl = _make_impl()
if impl is None:
return _NullLive2DView()
return impl(parent=parent, assets_dir=assets_dir)
_impl_class = None
def _make_impl():
"""延迟构造 _Live2DWidgetImpl 类(避免模块加载时触发 QApplication 依赖)。"""
global _impl_class
if _impl_class is not None:
return _impl_class
try:
# Import PyQt5 WebEngine modules
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtCore import QObject, Slot, QUrl, QTimer
from PySide6.QtCore import Qt as QtCoreEnum
from PySide6.QtWebEngineCore import QWebEngineSettings, QWebEnginePage
except Exception:
return None
class _PythonJSBridge(QObject):
"""QtWebChannel bridge: Python 端接收 JS 发来的消息。"""
def __init__(self, parent_live2d: "type"):
super().__init__(parent_live2d)
self._parent = parent_live2d
@Slot(str)
def onJsMessage(self, msg: str):
print(f"[JS→PY] {msg}", flush=True)
if msg == "ready":
self._parent._on_live2d_ready()
elif msg == "click":
self._parent._on_live2d_click()
class _Live2DWidgetImpl(QWebEngineView):
"""Live2D 渲染组件QWebEngineView"""
def __init__(self, parent=None, assets_dir: str | pathlib.Path | None = None):
super().__init__(parent)
self._assets_dir = pathlib.Path(assets_dir) if assets_dir else None
self._ready = False
# Start local HTTP server to serve assets (avoids file:// fetch restrictions)
self._http_port = self._start_http_server()
print(f'[DEBUG] Live2D HTTP server started on port {self._http_port}')
# Setup settings before loading HTML
settings = self.page().settings()
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessFileUrls, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.WebGLEnabled, True)
settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True)
self._setup_channel()
self._load_html()
self.setContextMenuPolicy(QtCoreEnum.NoContextMenu)
# Let mouse events pass through to underlying window for dragging
self.setAttribute(QtCoreEnum.WidgetAttribute.WA_TransparentForMouseEvents)
self.setFocusPolicy(QtCoreEnum.FocusPolicy.NoFocus)
# Timer to inject model data after page loads - need longer delay for external scripts to load
QTimer.singleShot(10000, self._try_inject)
def cleanup(self):
self._ready = False
def _inject_model_data(self):
"""Load model files in Python and inject into JavaScript via evaluateJavaScript."""
import sys
import base64
import json
print(f'[DEBUG] _inject_model_data called, assets_dir={self._assets_dir}')
if not self._assets_dir:
print('[DEBUG] No assets_dir, returning')
return
# Load model JSON - try both case variations
model_json_path = self._assets_dir / "march 7th.model3.json"
if not model_json_path.exists():
model_json_path = self._assets_dir / "March 7th.model3.json"
print(f'[DEBUG] Looking for model JSON at: {model_json_path}, exists={model_json_path.exists()}')
if not model_json_path.exists():
print('[DEBUG] Model JSON not found')
return
with open(model_json_path, 'r') as f:
model_json = json.load(f)
# Load moc3 file - try to find it based on model JSON
moc_filename = model_json.get('FileReferences', {}).get('Moc', '')
moc_path = self._assets_dir / moc_filename
# If file not found with exact case, try to find it
if not moc_path.exists():
# List directory and try to find a .moc3 file
for f in self._assets_dir.iterdir():
if f.suffix == '.moc3' or f.name.lower().endswith('.moc3'):
moc_path = f
break
print(f'[DEBUG] moc_path: {moc_path}, exists: {moc_path.exists()}')
if not moc_path.exists():
print('[DEBUG] Moc file not found')
return
with open(moc_path, 'rb') as f:
moc_base64 = base64.b64encode(f.read()).decode('utf-8')
# Pre-compute assets_dir_str
assets_dir_str = str(self._assets_dir.resolve()) if self._assets_dir else ""
# Write model data to temp JSON file to avoid large JS injection
import tempfile, json, os
tmp_json_path = self._assets_dir / "_model_data.json"
with open(tmp_json_path, 'w') as f:
json.dump({
"modelJson": model_json,
"mocBase64": moc_base64,
"mocFileName": moc_path.name,
"assetsDir": assets_dir_str
}, f)
print(f'[DEBUG] Model data written to {tmp_json_path}')
# Use HTTP URL instead of file:// to avoid WebEngine fetch restrictions
http_url = f"http://localhost:{self._http_port}/_model_data.json"
# Also set assetsDir to HTTP base URL so textures load via HTTP
http_assets_dir = f"http://localhost:{self._http_port}"
js = f"""
window._assetsDir = "{http_assets_dir}";
console.log('[DEBUG-PY] Fetching model data from: {http_url}');
fetch('{http_url}')
.then(r => r.json())
.then(data => {{
window._modelJson = data.modelJson;
window._mocBase64 = data.mocBase64;
window._mocFileName = data.mocFileName;
console.log('[DEBUG-PY] Data loaded. PIXI=' + typeof PIXI + ', Core=' + typeof Live2DCubismCore);
if(typeof window.startLive2D === 'function') {{
console.log('[DEBUG-PY] Calling startLive2D...');
window.startLive2D();
console.log('[DEBUG-PY] startLive2D() returned');
}} else {{
console.log('[DEBUG-PY] startLive2D not ready');
}}
}})
.catch(e => console.error('[DEBUG-PY] Fetch failed:', e));
"""
print(f'[DEBUG] Running JS injection (HTTP fetch), payload: {len(js)} chars')
self.page().runJavaScript(js)
print('[DEBUG] runJavaScript completed')
def _start_http_server(self):
"""Start a background HTTP server to serve Live2D assets with CORS headers."""
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('', 0))
port = sock.getsockname()[1]
sock.close()
class _CORSHTTPRequestHandler(SimpleHTTPRequestHandler):
"""HTTP handler with CORS headers for file:// WebEngine pages."""
def __init__(self, *args, directory=None, **kwargs):
# Pass directory to parent so translate_path uses correct base
super().__init__(*args, directory=directory, **kwargs)
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
super().end_headers()
self._http_server = HTTPServer(
('localhost', port),
lambda *args, **kwargs: _CORSHTTPRequestHandler(
directory=str(self._assets_dir), *args, **kwargs
)
)
self._http_server_thread = threading.Thread(
target=self._http_server.serve_forever, daemon=True
)
self._http_server_thread.start()
return port
def _setup_channel(self):
self._bridge = _PythonJSBridge(self)
self._channel = QWebChannel(self)
self._channel.registerObject("pybridge", self._bridge)
self.page().setWebChannel(self._channel)
def _load_html(self):
if self._assets_dir:
viewer_path = self._assets_dir / "viewer.html"
if viewer_path.exists():
url = QUrl.fromLocalFile(str(viewer_path.absolute()))
self.page().load(url)
return
self.setHtml("<html><body style='background:rgba(0,0,0,0)'></body></html>")
def _try_inject(self):
"""Try to inject model data after page loads."""
print('[DEBUG] _try_inject called')
self._inject_model_data()
def _on_js_injection_finished(self, result):
print(f'[DEBUG] JS injection finished, result: {result}')
@Slot(str)
def playMotion(self, name: str):
if not self._ready:
return
self.page().runJavaScript(
f"if(window.playMotion){{window.playMotion('{name}');}}"
f"if(window.requestRender){{window.requestRender();}}",
self._noop_callback
)
@Slot(str)
def setExpression(self, name: str):
if not self._ready:
return
self.page().runJavaScript(
f"if(window.setExpression){{window.setExpression('{name}');}}"
f"if(window.requestRender){{window.requestRender();}}",
self._noop_callback
)
@Slot()
def setRandomMotion(self):
if not self._ready:
return
self.page().runJavaScript(
"if(window.setRandomMotion){window.setRandomMotion();}",
self._noop_callback
)
def resizeLive2D(self, w: int, h: int):
self.page().runJavaScript(
f"if(window.resizeLive2D){{window.resizeLive2D({w},{h});}}",
self._noop_callback
)
def _noop_callback(self, result):
pass
def _on_live2d_ready(self):
self._ready = True
QTimer.singleShot(200, lambda: self.playMotion("miku_idle_01"))
def _on_live2d_click(self):
pass
def resizeEvent(self, event):
super().resizeEvent(event)
if self._assets_dir:
self.resizeLive2D(event.size().width(), event.size().height())
_impl_class = _Live2DWidgetImpl
return _impl_class