- _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
315 lines
12 KiB
Python
315 lines
12 KiB
Python
"""
|
||
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 或 Dummy(WebEngine 不可用时)。
|
||
|
||
注意:必须在校入 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 |