""" 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("") 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