Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import spaces | |
| import os | |
| import sys | |
| import json | |
| import re | |
| import zipfile | |
| import asyncio | |
| import tempfile | |
| import base64 | |
| import threading | |
| from pathlib import Path | |
| import subprocess | |
| # ββ Install deps ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def install_packages(): | |
| packages = [ | |
| "transformers>=4.40.0", | |
| "torch>=2.1.0", | |
| "accelerate>=0.27.0", | |
| "edge-tts", | |
| "sentencepiece", | |
| ] | |
| for pkg in packages: | |
| subprocess.run( | |
| [sys.executable, "-m", "pip", "install", pkg, "-q"], | |
| check=False, | |
| ) | |
| install_packages() | |
| import torch | |
| from transformers import AutoTokenizer, AutoModelForCausalLM | |
| import edge_tts | |
| # ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" # small, fast, reliable | |
| VRM_PATH = "model/Ani.vrm" | |
| ANIM_ZIP = "animation/all_vrma.zip" | |
| TTS_VOICE = "en-US-AriaNeural" # reliable fallback | |
| TTS_VOICE2 = "zh-CN-XiaoyiNeural" | |
| EMOTIONS_MAP = { | |
| "happy": {"blinking": True, "icon": "π"}, | |
| "sad": {"blinking": True, "icon": "π’"}, | |
| "angry": {"blinking": False, "icon": "π "}, | |
| "surprised": {"blinking": False, "icon": "π²"}, | |
| "fearful": {"blinking": True, "icon": "π¨"}, | |
| "disgusted": {"blinking": False, "icon": "π€’"}, | |
| "neutral": {"blinking": True, "icon": "π"}, | |
| "excited": {"blinking": True, "icon": "π€©"}, | |
| "confused": {"blinking": True, "icon": "π"}, | |
| "thinking": {"blinking": True, "icon": "π€"}, | |
| "laughing": {"blinking": False, "icon": "π"}, | |
| "crying": {"blinking": False, "icon": "π"}, | |
| "love": {"blinking": True, "icon": "π₯°"}, | |
| "shy": {"blinking": True, "icon": "π"}, | |
| "bored": {"blinking": True, "icon": "π"}, | |
| "sleepy": {"blinking": False, "icon": "π΄"}, | |
| "determined": {"blinking": True, "icon": "πͺ"}, | |
| "proud": {"blinking": True, "icon": "π"}, | |
| "embarrassed": {"blinking": True, "icon": "π³"}, | |
| "grateful": {"blinking": True, "icon": "π"}, | |
| "playful": {"blinking": True, "icon": "π"}, | |
| "serious": {"blinking": True, "icon": "π€"}, | |
| } | |
| # ββ VRMA list βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_vrma_list(): | |
| if not os.path.exists(ANIM_ZIP): | |
| return [] | |
| with zipfile.ZipFile(ANIM_ZIP, "r") as z: | |
| return [f for f in z.namelist() if f.endswith(".vrma")] | |
| VRMA_LIST = get_vrma_list() | |
| # ββ Model βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _tokenizer = None | |
| _model = None | |
| _lock = threading.Lock() | |
| def load_model(): | |
| global _tokenizer, _model | |
| if _model is not None: | |
| return | |
| with _lock: | |
| if _model is not None: | |
| return | |
| print(f"[ANI] Loading {MODEL_ID} β¦") | |
| _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) | |
| _model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_ID, | |
| torch_dtype=torch.float16, | |
| device_map="auto", | |
| low_cpu_mem_usage=True, | |
| ) | |
| _model.eval() | |
| print("[ANI] Model ready.") | |
| def build_system_prompt(): | |
| emo_str = ", ".join(EMOTIONS_MAP.keys()) | |
| anim_str = ", ".join(VRMA_LIST[:30]) if VRMA_LIST else "null" | |
| return ( | |
| "You are Ani, a friendly AI anime companion.\n" | |
| "Reply ONLY with valid JSON β no markdown, no extra text:\n" | |
| '{"response":"...", "emotion":"...", "animation":"...", "intensity":0.8}\n' | |
| f"Emotions: {emo_str}\n" | |
| f"Animations: {anim_str}\n" | |
| "Pick the best emotion and animation. Keep replies to 1-3 sentences." | |
| ) | |
| def generate_response(user_message: str, history: list) -> dict: | |
| load_model() | |
| system = build_system_prompt() | |
| messages = [{"role": "system", "content": system}] | |
| for h, a in history[-4:]: | |
| if h: | |
| messages.append({"role": "user", "content": h}) | |
| if a: | |
| try: | |
| messages.append({"role": "assistant", | |
| "content": json.loads(a).get("response", a)}) | |
| except Exception: | |
| messages.append({"role": "assistant", "content": a}) | |
| messages.append({"role": "user", "content": user_message}) | |
| text = _tokenizer.apply_chat_template( | |
| messages, tokenize=False, add_generation_prompt=True | |
| ) | |
| inputs = _tokenizer(text, return_tensors="pt").to(_model.device) | |
| with torch.no_grad(): | |
| out = _model.generate( | |
| **inputs, | |
| max_new_tokens=200, | |
| temperature=0.7, | |
| top_p=0.9, | |
| do_sample=True, | |
| pad_token_id=_tokenizer.eos_token_id, | |
| ) | |
| raw = _tokenizer.decode( | |
| out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True | |
| ).strip() | |
| try: | |
| m = re.search(r"\{.*\}", raw, re.DOTALL) | |
| result = json.loads(m.group()) if m else {} | |
| except Exception: | |
| result = {} | |
| result.setdefault("response", raw or "Hello!") | |
| result.setdefault("emotion", "neutral") | |
| result.setdefault("animation", None) | |
| result.setdefault("intensity", 0.7) | |
| if result["emotion"] not in EMOTIONS_MAP: | |
| result["emotion"] = "neutral" | |
| return result | |
| # ββ TTS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def _tts(text, voice, path): | |
| await edge_tts.Communicate(text, voice).save(path) | |
| def run_tts(text: str) -> str: | |
| tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) | |
| tmp.close() | |
| for voice in [TTS_VOICE2, TTS_VOICE]: | |
| try: | |
| asyncio.run(_tts(text, voice, tmp.name)) | |
| if os.path.getsize(tmp.name) > 0: | |
| with open(tmp.name, "rb") as f: | |
| b64 = base64.b64encode(f.read()).decode() | |
| os.unlink(tmp.name) | |
| return b64 | |
| except Exception as e: | |
| print(f"[TTS] {voice} failed: {e}") | |
| return "" | |
| # ββ VRM helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_vrm_b64() -> str: | |
| if os.path.exists(VRM_PATH): | |
| with open(VRM_PATH, "rb") as f: | |
| return base64.b64encode(f.read()).decode() | |
| return "" | |
| def get_vrma_b64(name: str) -> str: | |
| if not name or not os.path.exists(ANIM_ZIP): | |
| return "" | |
| with zipfile.ZipFile(ANIM_ZIP, "r") as z: | |
| for n in z.namelist(): | |
| if n == name or os.path.basename(n) == name: | |
| return base64.b64encode(z.read(n)).decode() | |
| return "" | |
| def get_idle_b64(): | |
| for p in ["natural2.vrma", "natural.vrma", "idle.vrma", "stand.vrma"]: | |
| b = get_vrma_b64(p) | |
| if b: | |
| return b | |
| if VRMA_LIST: | |
| return get_vrma_b64(VRMA_LIST[0]) | |
| return "" | |
| VRM_B64 = get_vrm_b64() | |
| IDLE_B64 = get_idle_b64() | |
| # ββ Chat handler ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def chat(user_msg: str, history: list): | |
| if not user_msg.strip(): | |
| return history, "{}" | |
| try: | |
| result = generate_response(user_msg, history) | |
| except Exception as e: | |
| result = {"response": f"Sorry, error: {e}", "emotion": "sad", | |
| "animation": None, "intensity": 0.5} | |
| text = result.get("response", "β¦") | |
| emotion = result.get("emotion", "neutral") | |
| animation = result.get("animation") | |
| intensity = result.get("intensity", 0.7) | |
| audio_b64 = run_tts(text) | |
| anim_b64 = get_vrma_b64(animation) if animation else "" | |
| blinking = EMOTIONS_MAP.get(emotion, EMOTIONS_MAP["neutral"])["blinking"] | |
| payload = json.dumps({ | |
| "type": "chat_response", | |
| "response": text, | |
| "emotion": emotion, | |
| "blinking": blinking, | |
| "animation_b64": anim_b64, | |
| "audio_b64": audio_b64, | |
| "intensity": intensity, | |
| }) | |
| new_history = history + [[user_msg, json.dumps(result)]] | |
| return new_history, payload | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HTML for the iframe-like inner panel | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| INNER_CSS = """ | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| background: #0d0d18; | |
| font-family: 'Segoe UI', system-ui, sans-serif; | |
| color: #e8e8f0; | |
| height: 100%; | |
| overflow: hidden; | |
| } | |
| #app { | |
| display: grid; | |
| grid-template-columns: 1fr 340px; | |
| height: 600px; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| border: 1px solid rgba(255,255,255,0.07); | |
| } | |
| /* ββ VRM side ββ */ | |
| #vrm-side { | |
| position: relative; | |
| background: linear-gradient(160deg,#0d0d1e,#080812,#0d0d1e); | |
| overflow: hidden; | |
| } | |
| #vrm-canvas { position:absolute; inset:0; width:100%; height:100%; display:block; } | |
| #status-bar { | |
| position:absolute; top:12px; left:12px; | |
| display:flex; align-items:center; gap:7px; | |
| background:rgba(8,8,18,.7); backdrop-filter:blur(10px); | |
| border:1px solid rgba(255,255,255,.07); border-radius:18px; | |
| padding:6px 13px; z-index:10; | |
| } | |
| #sdot { | |
| width:7px; height:7px; border-radius:50%; background:#4ade80; | |
| box-shadow:0 0 7px #4ade80; | |
| animation:dpulse 2s ease-in-out infinite; | |
| } | |
| @keyframes dpulse{0%,100%{box-shadow:0 0 6px #4ade80}50%{box-shadow:0 0 14px #4ade80,0 0 22px rgba(74,222,128,.4)}} | |
| #stext { font-size:11px; color:rgba(255,255,255,.6); } | |
| #emo-badge { | |
| position:absolute; bottom:16px; left:16px; | |
| background:rgba(8,8,18,.75); backdrop-filter:blur(10px); | |
| border:1px solid rgba(99,102,241,.28); border-radius:12px; | |
| padding:8px 14px; display:flex; align-items:center; gap:8px; z-index:10; | |
| } | |
| #emo-icon { font-size:18px; } | |
| #emo-label { font-size:12px; font-weight:600; color:rgba(255,255,255,.85); text-transform:capitalize; } | |
| .particle { | |
| position:absolute; border-radius:50%; pointer-events:none; | |
| animation:fup linear infinite; z-index:3; | |
| } | |
| @keyframes fup{0%{transform:translateY(110%) scale(0);opacity:0}8%{opacity:1}92%{opacity:.5}100%{transform:translateY(-10%) scale(1.2);opacity:0}} | |
| /* loader */ | |
| #loader { | |
| position:absolute; inset:0; z-index:20; | |
| background:rgba(8,8,18,.95); | |
| display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; | |
| transition:opacity .8s; | |
| } | |
| #loader.gone{opacity:0;pointer-events:none;} | |
| .ld-logo{font-size:48px;animation:lpulse 2s ease-in-out infinite;} | |
| @keyframes lpulse{0%,100%{filter:drop-shadow(0 0 14px rgba(99,102,241,.6))}50%{filter:drop-shadow(0 0 28px rgba(167,139,250,.8))}} | |
| .ld-title{font-size:20px;font-weight:700;background:linear-gradient(135deg,#a78bfa,#6366f1,#60a5fa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;} | |
| .ld-bar{width:140px;height:2px;background:rgba(255,255,255,.09);border-radius:1px;overflow:hidden;} | |
| .ld-fill{height:100%;background:linear-gradient(90deg,#6366f1,#a78bfa);border-radius:1px;animation:lsweep 1.6s ease-in-out infinite;} | |
| @keyframes lsweep{0%{width:0;margin-left:0}50%{width:50%;margin-left:25%}100%{width:0;margin-left:100%}} | |
| .ld-status{font-size:11px;color:rgba(255,255,255,.4);} | |
| /* ββ Chat side ββ */ | |
| #chat-side { | |
| display:flex; flex-direction:column; | |
| background:rgba(7,7,15,.97); | |
| border-left:1px solid rgba(255,255,255,.055); | |
| } | |
| #chat-header { | |
| padding:16px 18px 12px; | |
| border-bottom:1px solid rgba(255,255,255,.055); | |
| background:rgba(10,10,20,.9); flex-shrink:0; | |
| } | |
| #chat-title{font-size:16px;font-weight:700;background:linear-gradient(135deg,#a78bfa,#6366f1,#60a5fa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;} | |
| #chat-sub{font-size:10px;color:rgba(255,255,255,.35);margin-top:2px;} | |
| #model-chip{display:inline-flex;align-items:center;gap:4px;margin-top:6px;background:rgba(99,102,241,.12);border:1px solid rgba(99,102,241,.25);border-radius:16px;padding:2px 9px;font-size:9px;color:#a78bfa;} | |
| #msgs { | |
| flex:1; overflow-y:auto; padding:14px 12px; | |
| display:flex; flex-direction:column; gap:11px; | |
| scroll-behavior:smooth; | |
| } | |
| #msgs::-webkit-scrollbar{width:3px;} | |
| #msgs::-webkit-scrollbar-thumb{background:rgba(99,102,241,.25);border-radius:2px;} | |
| .mrow{display:flex;gap:8px;animation:min .25s ease;} | |
| .mrow.urow{flex-direction:row-reverse;} | |
| @keyframes min{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} | |
| .mav{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0;} | |
| .mav.aav{background:linear-gradient(135deg,#6366f1,#a78bfa);box-shadow:0 0 8px rgba(99,102,241,.4);} | |
| .mav.uav{background:linear-gradient(135deg,#374151,#4b5563);} | |
| .mwrap{display:flex;flex-direction:column;max-width:85%;} | |
| .urow .mwrap{align-items:flex-end;} | |
| .mbub{padding:8px 12px;font-size:12.5px;line-height:1.6;word-break:break-word;} | |
| .mbub.abub{background:rgba(99,102,241,.1);border:1px solid rgba(99,102,241,.17);border-radius:3px 13px 13px 13px;color:rgba(255,255,255,.87);} | |
| .mbub.ubub{background:linear-gradient(135deg,rgba(99,102,241,.2),rgba(139,92,246,.16));border:1px solid rgba(139,92,246,.2);border-radius:13px 3px 13px 13px;color:rgba(255,255,255,.92);} | |
| .mtime{font-size:9px;color:rgba(255,255,255,.25);margin-top:2px;} | |
| #typing{display:none;gap:8px;} | |
| #typing.show{display:flex;} | |
| .td{width:5px;height:5px;border-radius:50%;background:#6366f1;animation:tb 1.1s ease-in-out infinite;} | |
| .td:nth-child(2){animation-delay:.17s;}.td:nth-child(3){animation-delay:.34s;} | |
| @keyframes tb{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-5px);opacity:1}} | |
| #input-area{padding:12px;border-top:1px solid rgba(255,255,255,.055);background:rgba(8,8,18,.9);flex-shrink:0;} | |
| #irow{display:flex;gap:8px;align-items:flex-end;} | |
| #uinput{ | |
| flex:1;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.09); | |
| border-radius:16px;padding:10px 14px;color:rgba(255,255,255,.9); | |
| font-size:13px;font-family:inherit;resize:none;outline:none; | |
| line-height:1.5;min-height:42px;max-height:100px; | |
| transition:border-color .2s,box-shadow .2s; | |
| } | |
| #uinput:focus{border-color:rgba(99,102,241,.45);box-shadow:0 0 0 2px rgba(99,102,241,.08);} | |
| #uinput::placeholder{color:rgba(255,255,255,.25);} | |
| #sbtn{ | |
| width:42px;height:42px;border-radius:50%;border:none;cursor:pointer;flex-shrink:0; | |
| background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px; | |
| display:flex;align-items:center;justify-content:center; | |
| transition:all .2s;box-shadow:0 3px 9px rgba(99,102,241,.4); | |
| } | |
| #sbtn:hover:not(:disabled){transform:scale(1.08);box-shadow:0 4px 16px rgba(99,102,241,.6);} | |
| #sbtn:active:not(:disabled){transform:scale(.93);} | |
| #sbtn:disabled{opacity:.4;cursor:not-allowed;} | |
| </style> | |
| """ | |
| INNER_JS = """ | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.168.0/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.168.0/examples/jsm/", | |
| "@pixiv/three-vrm": "https://cdn.jsdelivr.net/npm/@pixiv/three-vrm@3.1.3/lib/three-vrm.module.js", | |
| "@pixiv/three-vrm-animation":"https://cdn.jsdelivr.net/npm/@pixiv/three-vrm-animation@3.1.3/lib/three-vrm-animation.module.js" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; | |
| import { VRMLoaderPlugin, VRMUtils } from '@pixiv/three-vrm'; | |
| import { VRMAnimationLoaderPlugin, createVRMAnimationClip } from '@pixiv/three-vrm-animation'; | |
| let renderer, scene, camera, clock; | |
| let vrm=null, mixer=null; | |
| let autoBlinkOn=true, isBlinking=false, blinkTimer=null; | |
| let lipInterval=null, audioCtx=null, curEmo='neutral'; | |
| let idleB64=''; | |
| const EMO_EXPR={ | |
| happy:{n:'happy',w:1},sad:{n:'sad',w:1},angry:{n:'angry',w:1}, | |
| surprised:{n:'surprised',w:1},fearful:{n:'sad',w:.8},disgusted:{n:'angry',w:.7}, | |
| neutral:{n:'neutral',w:0},excited:{n:'happy',w:1},confused:{n:'surprised',w:.6}, | |
| thinking:{n:'neutral',w:0},laughing:{n:'happy',w:1},crying:{n:'sad',w:1}, | |
| love:{n:'happy',w:.9},shy:{n:'happy',w:.5},bored:{n:'neutral',w:0}, | |
| sleepy:{n:'relaxed',w:.8},determined:{n:'angry',w:.4},proud:{n:'happy',w:.8}, | |
| embarrassed:{n:'sad',w:.5},grateful:{n:'happy',w:.7},playful:{n:'happy',w:.9},serious:{n:'neutral',w:0} | |
| }; | |
| const NO_BLINK=new Set(['angry','surprised','laughing','crying','sleepy','disgusted']); | |
| const EMO_ICON={happy:'π',sad:'π’',angry:'π ',surprised:'π²',fearful:'π¨',disgusted:'π€’', | |
| neutral:'π',excited:'π€©',confused:'π',thinking:'π€',laughing:'π',crying:'π', | |
| love:'π₯°',shy:'π',bored:'π',sleepy:'π΄',determined:'πͺ',proud:'π', | |
| embarrassed:'π³',grateful:'π',playful:'π',serious:'π€'}; | |
| function setSt(t){const e=document.getElementById('stext');if(e)e.textContent=t;} | |
| function setLd(t){const e=document.getElementById('ldst');if(e)e.textContent=t;} | |
| function hideLoader(){const e=document.getElementById('loader');if(e)setTimeout(()=>e.classList.add('gone'),600);} | |
| function initThree(){ | |
| const wrap=document.getElementById('vrm-side'); | |
| const canvas=document.getElementById('vrm-canvas'); | |
| renderer=new THREE.WebGLRenderer({canvas,antialias:true,alpha:true}); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio,2)); | |
| renderer.setSize(wrap.clientWidth,wrap.clientHeight); | |
| renderer.outputColorSpace=THREE.SRGBColorSpace; | |
| renderer.shadowMap.enabled=true; | |
| scene=new THREE.Scene(); | |
| scene.fog=new THREE.Fog(0x0a0a14,10,22); | |
| camera=new THREE.PerspectiveCamera(28,wrap.clientWidth/wrap.clientHeight,.1,100); | |
| camera.position.set(0,1.38,3.0); | |
| camera.lookAt(0,1.0,0); | |
| scene.add(new THREE.AmbientLight(0x6366f1,.45)); | |
| const kl=new THREE.DirectionalLight(0xffffff,1.1);kl.position.set(2,3,2);kl.castShadow=true;scene.add(kl); | |
| const fl=new THREE.DirectionalLight(0xa78bfa,.5);fl.position.set(-2,2,1);scene.add(fl); | |
| const rl=new THREE.DirectionalLight(0x60a5fa,.4);rl.position.set(0,2,-3);scene.add(rl); | |
| const pt=new THREE.PointLight(0x6366f1,.55,5);pt.position.set(0,.5,1.5);scene.add(pt); | |
| const floor=new THREE.Mesh(new THREE.PlaneGeometry(10,10), | |
| new THREE.MeshStandardMaterial({color:0x0d0d1a,roughness:.85,metalness:.1,transparent:true,opacity:.55})); | |
| floor.rotation.x=-Math.PI/2;floor.receiveShadow=true;scene.add(floor); | |
| const grid=new THREE.GridHelper(8,20,0x6366f1,0x1a1a2e); | |
| grid.material.opacity=.12;grid.material.transparent=true;scene.add(grid); | |
| clock=new THREE.Clock(); | |
| new ResizeObserver(()=>{ | |
| camera.aspect=wrap.clientWidth/wrap.clientHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(wrap.clientWidth,wrap.clientHeight); | |
| }).observe(wrap); | |
| animate(); | |
| } | |
| function animate(){ | |
| requestAnimationFrame(animate); | |
| const dt=clock.getDelta(); | |
| if(mixer)mixer.update(dt); | |
| if(vrm){ | |
| const t=clock.getElapsedTime(); | |
| try{ | |
| const sp=vrm.humanoid.getNormalizedBoneNode('spine'); | |
| if(sp){sp.rotation.z=Math.sin(t*.5)*.01;sp.rotation.x=Math.sin(t*.3)*.008;} | |
| const hd=vrm.humanoid.getNormalizedBoneNode('head'); | |
| if(hd){hd.rotation.y=Math.sin(t*.4)*.03;hd.rotation.x=Math.sin(t*.25)*.014;} | |
| }catch(e){} | |
| vrm.update(dt); | |
| } | |
| renderer.render(scene,camera); | |
| } | |
| function b64Blob(b64,mime='application/octet-stream'){ | |
| const by=Uint8Array.from(atob(b64),c=>c.charCodeAt(0)); | |
| return new Blob([by],{type:mime}); | |
| } | |
| function loadVRM(b64){ | |
| if(!b64||b64.length<100){setSt('No VRM file');hideLoader();return;} | |
| const url=URL.createObjectURL(b64Blob(b64)); | |
| const loader=new GLTFLoader(); | |
| loader.register(p=>new VRMLoaderPlugin(p)); | |
| loader.register(p=>new VRMAnimationLoaderPlugin(p)); | |
| loader.load(url,gltf=>{ | |
| const v=gltf.userData.vrm; | |
| if(!v){setSt('Invalid VRM');hideLoader();URL.revokeObjectURL(url);return;} | |
| VRMUtils.removeUnnecessaryJoints(v.scene); | |
| VRMUtils.removeUnnecessaryVertices(v.scene); | |
| if(vrm){scene.remove(vrm.scene);VRMUtils.deepDispose(vrm.scene);} | |
| vrm=v; scene.add(vrm.scene); | |
| vrm.scene.rotation.y=Math.PI; | |
| const box=new THREE.Box3().setFromObject(vrm.scene); | |
| const c=box.getCenter(new THREE.Vector3()); | |
| vrm.scene.position.sub(c); vrm.scene.position.y=0; | |
| URL.revokeObjectURL(url); | |
| setSt('Ani is ready β¨'); hideLoader(); startBlink(); | |
| if(idleB64)loadVRMA(idleB64,true); | |
| },undefined,e=>{console.error(e);setSt('VRM error');hideLoader();URL.revokeObjectURL(url);}); | |
| } | |
| function loadVRMA(b64,loop=true,onDone=null){ | |
| if(!vrm||!b64||b64.length<10)return; | |
| const url=URL.createObjectURL(b64Blob(b64)); | |
| const loader=new GLTFLoader(); | |
| loader.register(p=>new VRMAnimationLoaderPlugin(p)); | |
| loader.load(url,gltf=>{ | |
| const anims=gltf.userData.vrmAnimations; | |
| if(!anims||!anims.length){URL.revokeObjectURL(url);return;} | |
| const clip=createVRMAnimationClip(anims[0],vrm); | |
| if(mixer)mixer.stopAllAction(); | |
| mixer=new THREE.AnimationMixer(vrm.scene); | |
| const act=mixer.clipAction(clip); | |
| act.setLoop(loop?THREE.LoopRepeat:THREE.LoopOnce,Infinity); | |
| act.clampWhenFinished=!loop; act.play(); | |
| if(onDone&&!loop)mixer.addEventListener('finished',()=>{onDone();URL.revokeObjectURL(url);}); | |
| else URL.revokeObjectURL(url); | |
| },undefined,()=>URL.revokeObjectURL(url)); | |
| } | |
| function startBlink(){ | |
| if(blinkTimer)clearInterval(blinkTimer); | |
| blinkTimer=setInterval(()=>{if(autoBlinkOn&&vrm&&!isBlinking)doBlink();},2200+Math.random()*2200); | |
| } | |
| function doBlink(){ | |
| if(!vrm||isBlinking)return; | |
| const em=vrm.expressionManager;if(!em)return; | |
| isBlinking=true;let t=0;const step=16,dur=130; | |
| const id=setInterval(()=>{ | |
| t+=step;const w=Math.max(0,Math.sin((t/dur)*Math.PI)); | |
| try{em.setValue('blink',w);}catch(e){} | |
| if(t>=dur){clearInterval(id);try{em.setValue('blink',0);}catch(e){}isBlinking=false;} | |
| },step); | |
| } | |
| function applyEmotion(name,intensity=1){ | |
| if(!vrm)return; | |
| const em=vrm.expressionManager;if(!em)return; | |
| ['happy','sad','angry','surprised','relaxed','neutral'].forEach(e=>{try{em.setValue(e,0);}catch(x){}}); | |
| const m=EMO_EXPR[name]||EMO_EXPR.neutral; | |
| if(m.w>0)try{em.setValue(m.n,m.w*intensity);}catch(e){} | |
| autoBlinkOn=!NO_BLINK.has(name); curEmo=name; | |
| const ic=document.getElementById('emo-icon');const lb=document.getElementById('emo-label'); | |
| if(ic)ic.textContent=EMO_ICON[name]||'π'; | |
| if(lb)lb.textContent=name; | |
| } | |
| function startLip(arrayBuf){ | |
| stopLip(); | |
| if(!audioCtx)audioCtx=new(window.AudioContext||window.webkitAudioContext)(); | |
| audioCtx.decodeAudioData(arrayBuf,decoded=>{ | |
| const src=audioCtx.createBufferSource();src.buffer=decoded; | |
| const an=audioCtx.createAnalyser();an.fftSize=256; | |
| src.connect(an);an.connect(audioCtx.destination);src.start(0); | |
| const d=new Uint8Array(an.frequencyBinCount); | |
| lipInterval=setInterval(()=>{ | |
| an.getByteFrequencyData(d); | |
| const v=d.slice(2,22),avg=v.reduce((a,b)=>a+b,0)/v.length; | |
| const mw=Math.min(1,avg/75); | |
| if(vrm){const em=vrm.expressionManager;if(em)try{em.setValue('aa',mw*.85);}catch(e){}} | |
| },33); | |
| src.addEventListener('ended',()=>{stopLip();autoBlinkOn=!NO_BLINK.has(curEmo);}); | |
| },()=>fallbackLip()); | |
| } | |
| function fallbackLip(){ | |
| let t=0; | |
| lipInterval=setInterval(()=>{ | |
| t+=.1;const m=.25+.45*Math.abs(Math.sin(t*7))*Math.abs(Math.sin(t*2.7)); | |
| if(vrm){const em=vrm.expressionManager;if(em)try{em.setValue('aa',m);}catch(e){}} | |
| },50); | |
| setTimeout(stopLip,4000); | |
| } | |
| function stopLip(){ | |
| if(lipInterval){clearInterval(lipInterval);lipInterval=null;} | |
| if(vrm){const em=vrm.expressionManager;if(em){try{em.setValue('aa',0);}catch(e){}}} | |
| } | |
| // Exposed to window for cross-script access | |
| window._vrmHandlePayload=function(payload){ | |
| if(!payload||payload==='{}')return; | |
| let d;try{d=JSON.parse(payload);}catch(e){return;} | |
| if(!d||d.type!=='chat_response')return; | |
| if(d.emotion)applyEmotion(d.emotion,d.intensity||.8); | |
| if(d.animation_b64&&d.animation_b64.length>10) | |
| loadVRMA(d.animation_b64,false,()=>{if(idleB64)loadVRMA(idleB64,true);}); | |
| if(d.audio_b64&&d.audio_b64.length>10){ | |
| const bin=atob(d.audio_b64),buf=new Uint8Array(bin.length); | |
| for(let i=0;i<bin.length;i++)buf[i]=bin.charCodeAt(i); | |
| startLip(buf.buffer.slice(0)); | |
| // Play audio | |
| try{new Audio('data:audio/mp3;base64,'+d.audio_b64).play().catch(()=>{});}catch(e){} | |
| } | |
| }; | |
| function mkParticles(){ | |
| const side=document.getElementById('vrm-side');if(!side)return; | |
| const cols=['rgba(99,102,241,','rgba(139,92,246,','rgba(96,165,250,']; | |
| for(let i=0;i<12;i++){ | |
| const p=document.createElement('div');p.className='particle'; | |
| const sz=2+Math.random()*3,col=cols[i%3]; | |
| p.style.cssText=`width:${sz}px;height:${sz}px;left:${Math.random()*100}%;`+ | |
| `background:${col}${.3+Math.random()*.4});box-shadow:0 0 ${sz*2}px ${col}.6);`+ | |
| `animation-duration:${9+Math.random()*12}s;animation-delay:${Math.random()*10}s;`; | |
| side.appendChild(p); | |
| } | |
| } | |
| (function boot(){ | |
| initThree(); mkParticles(); | |
| setLd('Loading 3D modelβ¦'); | |
| const vb=document.getElementById('__vrm_b64__'); | |
| const ib=document.getElementById('__idle_b64__'); | |
| idleB64=(ib&&ib.textContent.trim().length>100)?ib.textContent.trim():''; | |
| if(vb&&vb.textContent.trim().length>100){ | |
| loadVRM(vb.textContent.trim()); | |
| }else{ | |
| setSt('model/Ani.vrm not found'); hideLoader(); | |
| } | |
| })(); | |
| </script> | |
| <script> | |
| // Chat UI β plain script (no module needed) | |
| (function(){ | |
| const msgs = document.getElementById('msgs'); | |
| const input = document.getElementById('uinput'); | |
| const btn = document.getElementById('sbtn'); | |
| const typing = document.getElementById('typing'); | |
| let lastPayload=''; | |
| function now(){const d=new Date();return d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0');} | |
| function addMsg(text,role){ | |
| const row=document.createElement('div');row.className='mrow'+(role==='user'?' urow':''); | |
| const av=document.createElement('div');av.className='mav '+(role==='user'?'uav':'aav');av.textContent=role==='user'?'π€':'πΈ'; | |
| const mw=document.createElement('div');mw.className='mwrap'; | |
| const b=document.createElement('div');b.className='mbub '+(role==='user'?'ubub':'abub');b.textContent=text; | |
| const t=document.createElement('div');t.className='mtime';t.textContent=now(); | |
| mw.appendChild(b);mw.appendChild(t); | |
| if(role==='user'){row.appendChild(mw);row.appendChild(av);}else{row.appendChild(av);row.appendChild(mw);} | |
| msgs.insertBefore(row,document.getElementById('typing')); | |
| msgs.scrollTop=msgs.scrollHeight; | |
| } | |
| function showTyping(on){typing.classList.toggle('show',on);msgs.scrollTop=msgs.scrollHeight;} | |
| input.addEventListener('input',()=>{input.style.height='auto';input.style.height=Math.min(input.scrollHeight,100)+'px';}); | |
| input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}}); | |
| btn.addEventListener('click',send); | |
| function send(){ | |
| const txt=input.value.trim(); | |
| if(!txt||btn.disabled)return; | |
| addMsg(txt,'user'); | |
| input.value='';input.style.height='auto'; | |
| btn.disabled=true;showTyping(true); | |
| // Talk to Gradio via parent window postMessage | |
| window.parent.postMessage({type:'ani_chat',message:txt},'*'); | |
| } | |
| // Receive payload from parent | |
| window.addEventListener('message',e=>{ | |
| const d=e.data; | |
| if(!d||d.type!=='ani_payload')return; | |
| const p=d.payload; | |
| if(!p||p===lastPayload)return; | |
| lastPayload=p; | |
| showTyping(false); | |
| btn.disabled=false; | |
| try{ | |
| const obj=JSON.parse(p); | |
| if(obj.response)addMsg(obj.response,'ai'); | |
| if(window._vrmHandlePayload)window._vrmHandlePayload(p); | |
| }catch(ex){} | |
| }); | |
| })(); | |
| </script> | |
| """ | |
| def build_inner_html(): | |
| vrm_b64_escaped = VRM_B64 if VRM_B64 else "" | |
| idle_b64_escaped = IDLE_B64 if IDLE_B64 else "" | |
| return f"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| {INNER_CSS} | |
| </head> | |
| <body> | |
| <div id="__vrm_b64__" style="display:none">{vrm_b64_escaped}</div> | |
| <div id="__idle_b64__" style="display:none">{idle_b64_escaped}</div> | |
| <div id="app"> | |
| <div id="vrm-side"> | |
| <canvas id="vrm-canvas"></canvas> | |
| <div id="loader"> | |
| <div class="ld-logo">πΈ</div> | |
| <div class="ld-title">Ani</div> | |
| <div class="ld-bar"><div class="ld-fill"></div></div> | |
| <div class="ld-status" id="ldst">Startingβ¦</div> | |
| </div> | |
| <div id="status-bar"><div id="sdot"></div><span id="stext">Loadingβ¦</span></div> | |
| <div id="emo-badge"><span id="emo-icon">π</span><span id="emo-label">neutral</span></div> | |
| </div> | |
| <div id="chat-side"> | |
| <div id="chat-header"> | |
| <div id="chat-title">β¨ Ani</div> | |
| <div id="chat-sub">AI Anime Companion</div> | |
| <div id="model-chip">π€ Qwen2.5-0.5B Β· ZeroGPU</div> | |
| </div> | |
| <div id="msgs"> | |
| <div class="mrow"> | |
| <div class="mav aav">πΈ</div> | |
| <div class="mwrap"> | |
| <div class="mbub abub">Hello! I'm Ani β¨ How can I help you today?</div> | |
| <div class="mtime">just now</div> | |
| </div> | |
| </div> | |
| <div id="typing"> | |
| <div class="mav aav">πΈ</div> | |
| <div class="mwrap"> | |
| <div class="mbub abub" style="display:flex;align-items:center;gap:5px;padding:9px 12px;"> | |
| <div class="td"></div><div class="td"></div><div class="td"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="input-area"> | |
| <div id="irow"> | |
| <textarea id="uinput" placeholder="Talk to Aniβ¦" rows="1"></textarea> | |
| <button id="sbtn">β€</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {INNER_JS} | |
| </body> | |
| </html>""" | |
| INNER_HTML = build_inner_html() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Gradio app | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| GRADIO_CSS = """ | |
| .gradio-container { max-width: 100% !important; padding: 8px !important; background: #0a0a0f !important; } | |
| footer { display: none !important; } | |
| #ani-iframe { width:100%; border:none; border-radius:12px; height:608px; display:block; } | |
| #payload-box textarea { display:none !important; } | |
| #payload-box { display:none !important; } | |
| #chat-hist { display:none !important; } | |
| """ | |
| with gr.Blocks(title="Ani AI Companion") as demo: | |
| gr.HTML("<style>" + GRADIO_CSS.replace("<style>","").replace("</style>","") + "</style>") | |
| chat_history = gr.State([]) | |
| payload_store = gr.State("{}") | |
| # ββ Iframe holding the full custom UI ββ | |
| iframe_html = f""" | |
| <iframe id="ani-iframe" | |
| srcdoc="{INNER_HTML.replace(chr(34), '"').replace(chr(39), ''')}" | |
| sandbox="allow-scripts allow-same-origin" | |
| style="width:100%;height:608px;border:none;border-radius:12px;display:block;" | |
| ></iframe> | |
| <script> | |
| // Bridge: receive messages from iframe, call Gradio, send payload back | |
| window.addEventListener('message', function(e) {{ | |
| if (!e.data || e.data.type !== 'ani_chat') return; | |
| const msg = e.data.message; | |
| if (!msg) return; | |
| // Trigger Gradio submit | |
| const inp = document.querySelector('#gr-msg-input textarea') || | |
| document.querySelector('#gr-msg-input input'); | |
| if (inp) {{ | |
| const nv = Object.getOwnPropertyDescriptor( | |
| inp.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, | |
| 'value' | |
| ); | |
| if (nv && nv.set) nv.set.call(inp, msg); | |
| inp.dispatchEvent(new Event('input', {{bubbles: true}})); | |
| }} | |
| setTimeout(() => {{ | |
| const gb = document.getElementById('gr-submit-btn'); | |
| if (gb) gb.click(); | |
| }}, 60); | |
| }}); | |
| // Watch payload textbox and forward to iframe | |
| function watchPayload() {{ | |
| const sel = [ | |
| '#gr-payload-out textarea', | |
| '#gr-payload-out input', | |
| ]; | |
| let last = ''; | |
| setInterval(() => {{ | |
| let val = ''; | |
| for (const s of sel) {{ | |
| const el = document.querySelector(s); | |
| if (el && el.value && el.value !== '{{}}') {{ val = el.value; break; }} | |
| }} | |
| if (!val || val === last) return; | |
| last = val; | |
| const iframe = document.getElementById('ani-iframe'); | |
| if (iframe && iframe.contentWindow) {{ | |
| iframe.contentWindow.postMessage({{type:'ani_payload', payload:val}}, '*'); | |
| }} | |
| }}, 300); | |
| }} | |
| watchPayload(); | |
| </script> | |
| """ | |
| gr.HTML(iframe_html) | |
| # ββ Hidden Gradio controls ββ | |
| with gr.Row(visible=False): | |
| gr_msg = gr.Textbox(value="", label="msg", elem_id="gr-msg-input") | |
| gr_btn = gr.Button("go", elem_id="gr-submit-btn") | |
| gr_pay = gr.Textbox(value="{}", label="payload",elem_id="gr-payload-out") | |
| gr_hist = gr.Chatbot(value=[], label="history", elem_id="gr-chat-hist") | |
| # ββ Backend ββ | |
| def on_submit(message: str, history: list): | |
| if not message or not message.strip(): | |
| return history, "{}" | |
| new_history, payload = chat(message, history)[:2] | |
| return new_history, payload | |
| gr_btn.click( | |
| fn=on_submit, | |
| inputs=[gr_msg, chat_history], | |
| outputs=[chat_history, gr_pay], | |
| ) | |
| # Sync state β chatbot widget (hidden but keeps history) | |
| chat_history.change( | |
| fn=lambda h: h, | |
| inputs=[chat_history], | |
| outputs=[gr_hist], | |
| ) | |
| # JS: whenever gr_pay changes, the watchPayload loop above picks it up automatically | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", server_port=7860) |