Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>🎬 Museum AI Studio</title> | |
| <style> | |
| :root { | |
| --bg: #0d1117; | |
| --panel: #161b22; | |
| --accent: #58a6ff; | |
| --accent2: #f0883e; | |
| --text: #c9d1d9; | |
| --text-dim: #8b949e; | |
| --success: #3fb950; | |
| --danger: #f85149; | |
| } | |
| * { margin:0; padding:0; box-sizing:border-box; } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; | |
| overflow: hidden; | |
| height: 100vh; | |
| width: 100vw; | |
| } | |
| #app { display:flex; height:100vh; width:100vw; } | |
| /* ─── Sidebar ──────────────────────────────────────────── */ | |
| #sidebar { | |
| width: 260px; | |
| background: var(--panel); | |
| border-right: 1px solid #30363d; | |
| display:flex; | |
| flex-direction:column; | |
| padding: 16px; | |
| gap: 12px; | |
| overflow-y: auto; | |
| } | |
| #sidebar h1 { | |
| font-size: 1.3rem; | |
| color: var(--accent); | |
| text-align:center; | |
| margin-bottom: 8px; | |
| } | |
| #sidebar h1 span { display:block; font-size:0.75rem; color:var(--text-dim); } | |
| .mode-btn { | |
| display:flex; align-items:center; gap:10px; | |
| padding: 12px 14px; | |
| border-radius: 10px; | |
| border: 1px solid #30363d; | |
| background: transparent; | |
| color: var(--text); | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| font-size: 0.95rem; | |
| } | |
| .mode-btn:hover { background: #21262d; border-color: var(--accent); } | |
| .mode-btn.active { background: rgba(88,166,255,0.12); border-color: var(--accent); color: var(--accent); } | |
| .mode-btn .icon { font-size: 1.4rem; width: 32px; text-align:center; } | |
| /* Style selector (studio only) */ | |
| .style-group { display:none; flex-direction:column; gap:8px; margin-top:4px; } | |
| .style-group.visible { display:flex; } | |
| .style-group h3 { font-size:0.8rem; color:var(--text-dim); text-transform:uppercase; letter-spacing:1px; } | |
| .style-btn { | |
| padding: 8px 12px; border-radius:8px; border:1px solid #30363d; | |
| background:#0d1117; color:var(--text); cursor:pointer; font-size:0.9rem; | |
| transition:0.2s; | |
| } | |
| .style-btn:hover, .style-btn.active { border-color:var(--accent2); color:var(--accent2); background:rgba(240,136,62,0.08); } | |
| /* Filter selector (face only) */ | |
| .filter-group { display:none; flex-direction:column; gap:8px; margin-top:4px; } | |
| .filter-group.visible { display:flex; } | |
| .filter-group h3 { font-size:0.8rem; color:var(--text-dim); text-transform:uppercase; letter-spacing:1px; } | |
| .filter-btn { | |
| padding: 8px 12px; border-radius:8px; border:1px solid #30363d; | |
| background:#0d1117; color:var(--text); cursor:pointer; font-size:0.9rem; | |
| transition:0.2s; | |
| } | |
| .filter-btn:hover, .filter-btn.active { border-color:#d2a8ff; color:#d2a8ff; background:rgba(210,168,255,0.08); } | |
| /* RPS controls */ | |
| .rps-group { display:none; flex-direction:column; gap:10px; margin-top:4px; } | |
| .rps-group.visible { display:flex; } | |
| .rps-group h3 { font-size:0.8rem; color:var(--text-dim); text-transform:uppercase; letter-spacing:1px; } | |
| .rps-btn { | |
| padding: 14px; border-radius:10px; border:1px solid #30363d; | |
| background:#0d1117; color:var(--text); cursor:pointer; font-size:1.1rem; | |
| transition:0.2s; | |
| } | |
| .rps-btn:hover { border-color:var(--success); color:var(--success); } | |
| .rps-result { font-size:1rem; padding:10px; border-radius:8px; background:#21262d; text-align:center; } | |
| .rps-score { display:flex; justify-content:space-between; font-size:0.85rem; color:var(--text-dim); } | |
| /* Painter controls */ | |
| .painter-group { display:none; flex-direction:column; gap:10px; margin-top:4px; } | |
| .painter-group.visible { display:flex; } | |
| .painter-group h3 { font-size:0.8rem; color:var(--text-dim); text-transform:uppercase; letter-spacing:1px; } | |
| .painter-btn { padding:10px; border-radius:8px; border:1px solid #30363d; background:#0d1117; color:var(--text); cursor:pointer; transition:0.2s; } | |
| .painter-btn:hover { border-color:var(--danger); color:var(--danger); } | |
| /* Pose controls */ | |
| .pose-group { display:none; flex-direction:column; gap:10px; margin-top:4px; } | |
| .pose-group.visible { display:flex; } | |
| .pose-group h3 { font-size:0.8rem; color:var(--text-dim); text-transform:uppercase; letter-spacing:1px; } | |
| .pose-target { font-size:0.95rem; padding:12px; border-radius:8px; background:#21262d; border-left:3px solid var(--accent); } | |
| .pose-score { font-size:2rem; color:var(--success); text-align:center; } | |
| .pose-timer { font-size:1rem; text-align:center; color:var(--accent2); } | |
| /* ─── Main Viewport ────────────────────────────────────── */ | |
| #main { flex:1; position:relative; display:flex; flex-direction:column; } | |
| #video-container { | |
| flex:1; position:relative; display:flex; align-items:center; justify-content:center; | |
| background: #000; | |
| } | |
| video, canvas { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; } | |
| #processed-canvas { z-index: 5; } | |
| #ar-canvas { z-index: 10; pointer-events:none; } | |
| #webcam { transform: scaleX(-1); opacity:0; } | |
| /* Loading overlay */ | |
| #loading { | |
| position:absolute; inset:0; z-index:100; | |
| background:rgba(13,17,23,0.95); | |
| display:flex; flex-direction:column; align-items:center; justify-content:center; gap:16px; | |
| } | |
| #loading.hidden { display:none; } | |
| .spinner { | |
| width: 48px; height: 48px; | |
| border: 4px solid var(--panel); | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| #loading p { color: var(--text-dim); font-size:0.95rem; } | |
| /* Status bar */ | |
| #status-bar { | |
| height: 36px; background: var(--panel); border-top:1px solid #30363d; | |
| display:flex; align-items:center; justify-content:space-between; | |
| padding: 0 16px; font-size: 0.8rem; color: var(--text-dim); | |
| } | |
| #status-bar .dot { width:8px; height:8px; border-radius:50%; display:inline-block; margin-right:6px; } | |
| .dot.green { background:var(--success); } | |
| .dot.red { background:var(--danger); } | |
| .dot.yellow { background:var(--accent2); } | |
| /* Countdown overlay */ | |
| #countdown-overlay { | |
| position:absolute; inset:0; z-index:20; pointer-events:none; | |
| display:flex; align-items:center; justify-content:center; | |
| } | |
| #countdown-text { | |
| font-size: 8rem; font-weight:800; color:var(--accent); | |
| text-shadow: 0 0 30px rgba(88,166,255,0.5); | |
| opacity:0; transition: opacity 0.15s; | |
| } | |
| /* Help tooltip */ | |
| .help-text { font-size:0.75rem; color:var(--text-dim); line-height:1.4; margin-top:8px; padding:8px; background:#0d1117; border-radius:6px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <!-- Sidebar --> | |
| <div id="sidebar"> | |
| <h1>🎬 Museum AI <span>Interactive Studio</span></h1> | |
| <button class="mode-btn active" data-mode="studio" onclick="setMode('studio')"> | |
| <span class="icon">🎨</span> Anime Studio | |
| </button> | |
| <div id="style-group" class="style-group visible"> | |
| <h3>🎭 Choose Style</h3> | |
| <button class="style-btn active" data-style="hayao" onclick="setStyle('hayao')">🍃 Hayao / Ghibli</button> | |
| <button class="style-btn" data-style="shinkai" onclick="setStyle('shinkai')">✨ Shinkai / Your Name</button> | |
| </div> | |
| <button class="mode-btn" data-mode="pose" onclick="setMode('pose')"> | |
| <span class="icon">🧘</span> Pose Challenge | |
| </button> | |
| <div id="pose-group" class="pose-group"> | |
| <h3>🎯 Target Pose</h3> | |
| <div id="pose-target" class="pose-target">Loading...</div> | |
| <div id="pose-score" class="pose-score">0%</div> | |
| <div id="pose-timer" class="pose-timer"></div> | |
| <button class="painter-btn" onclick="nextPose()">⏭ Skip Pose</button> | |
| </div> | |
| <button class="mode-btn" data-mode="face" onclick="setMode('face')"> | |
| <span class="icon">😎</span> Face Filters | |
| </button> | |
| <div id="filter-group" class="filter-group"> | |
| <h3>🎭 Choose Filter</h3> | |
| <button class="filter-btn active" data-filter="glasses" onclick="setFilter('glasses')">👓 Glasses</button> | |
| <button class="filter-btn" data-filter="cat" onclick="setFilter('cat')">🐱 Cat Ears</button> | |
| <button class="filter-btn" data-filter="crown" onclick="setFilter('crown')">👑 Crown</button> | |
| <button class="filter-btn" data-filter="mustache" onclick="setFilter('mustache')">🥸 Mustache</button> | |
| <button class="filter-btn" data-filter="anime" onclick="setFilter('anime')">👀 Anime Eyes</button> | |
| <button class="filter-btn" data-filter="none" onclick="setFilter('none')">❌ No Filter</button> | |
| </div> | |
| <button class="mode-btn" data-mode="painter" onclick="setMode('painter')"> | |
| <span class="icon">✋</span> Hand Painter | |
| </button> | |
| <div id="painter-group" class="painter-group"> | |
| <h3>🎨 Controls</h3> | |
| <button class="painter-btn" onclick="clearCanvas()">🗑️ Clear Canvas</button> | |
| <button class="painter-btn" onclick="saveDrawing()">💾 Save Drawing</button> | |
| <p class="help-text">Point your index finger to draw. Make a fist to stop. Show open palm to change color.</p> | |
| </div> | |
| <button class="mode-btn" data-mode="rps" onclick="setMode('rps')"> | |
| <span class="icon">✊</span> Rock-Paper-Scissors | |
| </button> | |
| <div id="rps-group" class="rps-group"> | |
| <h3>🎮 Game</h3> | |
| <div class="rps-score"><span>You: <b id="rps-player-score">0</b></span><span>AI: <b id="rps-ai-score">0</b></span></div> | |
| <button class="rps-btn" onclick="startRPSRound()" id="rps-start-btn">🚀 Start Round</button> | |
| <div id="rps-result" class="rps-result" style="display:none;"></div> | |
| <p class="help-text">Show your hand: ✊ Rock (fist) | ✋ Paper (open) | ✌️ Scissors (peace sign)</p> | |
| </div> | |
| <div style="margin-top:auto; padding-top:12px; border-top:1px solid #30363d;"> | |
| <div id="ws-status"><span class="dot red"></span>Disconnected</div> | |
| <div style="font-size:0.7rem; color:var(--text-dim); margin-top:4px;">Press Q in any mode to quit camera</div> | |
| </div> | |
| </div> | |
| <!-- Main Viewport --> | |
| <div id="main"> | |
| <div id="video-container"> | |
| <video id="webcam" autoplay playsinline></video> | |
| <canvas id="processed-canvas"></canvas> | |
| <canvas id="ar-canvas"></canvas> | |
| <div id="countdown-overlay"><div id="countdown-text"></div></div> | |
| <div id="loading"> | |
| <div class="spinner"></div> | |
| <p id="loading-text">Starting camera & MediaPipe...</p> | |
| </div> | |
| </div> | |
| <div id="status-bar"> | |
| <span id="mode-display">🎨 Anime Studio — Hayao / Ghibli style</span> | |
| <span id="fps-display">FPS: --</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- MediaPipe Tasks Vision (CDN) --> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/vision_bundle.js" crossorigin="anonymous"></script> | |
| <script> | |
| const MODELS = { | |
| hayao: { name: 'Hayao / Ghibli' }, | |
| shinkai: { name: 'Shinkai / Your Name' }, | |
| }; | |
| const state = { | |
| mode: 'studio', | |
| style: 'hayao', | |
| filter: 'glasses', | |
| ws: null, | |
| clientId: 'user_' + Math.random().toString(36).substr(2, 8), | |
| cameraReady: false, | |
| mpReady: false, | |
| faceLandmarker: null, | |
| poseLandmarker: null, | |
| handLandmarker: null, | |
| rpsScore: { player: 0, ai: 0 }, | |
| rpsState: 'idle', | |
| paintCanvas: null, | |
| paintCtx: null, | |
| paintColor: '#58a6ff', | |
| paintColors: ['#58a6ff', '#f0883e', '#3fb950', '#d2a8ff', '#f85149', '#fff'], | |
| paintColorIdx: 0, | |
| poseTarget: null, | |
| poseHoldStart: null, | |
| poseScore: 0, | |
| poseTargets: [ | |
| { name: "T-Pose", desc: "Stretch arms out like a T!", keyPts: {11:[0.2,0.5],12:[0.8,0.5],13:[0.1,0.5],14:[0.9,0.5],15:[0.0,0.5],16:[1.0,0.5]} }, | |
| { name: "Victory", desc: "Raise both arms up high!", keyPts: {11:[0.35,0.4],12:[0.65,0.4],13:[0.25,0.25],14:[0.75,0.25],15:[0.2,0.1],16:[0.8,0.1]} }, | |
| { name: "Squat", desc: "Bend knees like sitting!", keyPts: {23:[0.35,0.7],24:[0.65,0.7],25:[0.3,0.8],26:[0.7,0.8],27:[0.35,0.95],28:[0.65,0.95]} }, | |
| { name: "Dab", desc: "One arm up, one across!", keyPts: {11:[0.4,0.4],12:[0.65,0.4],13:[0.15,0.2],14:[0.85,0.6],15:[0.05,0.15],16:[0.95,0.7]} }, | |
| { name: "Star", desc: "Jump and spread arms & legs!", keyPts: {11:[0.3,0.4],12:[0.7,0.4],13:[0.15,0.25],14:[0.85,0.25],15:[0.05,0.15],16:[0.95,0.15],23:[0.35,0.6],24:[0.65,0.6],25:[0.2,0.8],26:[0.8,0.8],27:[0.1,0.95],28:[0.9,0.95]} }, | |
| ], | |
| lastFrameTime: 0, | |
| fps: 0, | |
| }; | |
| const POSE_CONNECTIONS = [ | |
| [11,13],[13,15],[12,14],[14,16],[11,12], | |
| [11,23],[12,24],[23,24],[23,25],[25,27],[24,26],[26,28] | |
| ]; | |
| const video = document.getElementById('webcam'); | |
| const processedCanvas = document.getElementById('processed-canvas'); | |
| const arCanvas = document.getElementById('ar-canvas'); | |
| const processedCtx = processedCanvas.getContext('2d'); | |
| const arCtx = arCanvas.getContext('2d'); | |
| const loadingEl = document.getElementById('loading'); | |
| const loadingText = document.getElementById('loading-text'); | |
| const countdownText = document.getElementById('countdown-text'); | |
| async function init() { | |
| try { | |
| loadingText.textContent = "Requesting camera access..."; | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { width: 640, height: 480, facingMode: 'user' }, | |
| audio: false | |
| }); | |
| video.srcObject = stream; | |
| await new Promise(r => video.onloadedmetadata = r); | |
| video.play(); | |
| state.cameraReady = true; | |
| const w = video.videoWidth || 640; | |
| const h = video.videoHeight || 480; | |
| processedCanvas.width = w; processedCanvas.height = h; | |
| arCanvas.width = w; arCanvas.height = h; | |
| loadingText.textContent = "Loading MediaPipe AI models (this may take 10-20s)..."; | |
| await initMediaPipe(); | |
| state.mpReady = true; | |
| loadingText.textContent = "Connecting to style transfer server..."; | |
| await connectWebSocket(); | |
| loadingEl.classList.add('hidden'); | |
| requestAnimationFrame(mainLoop); | |
| } catch (err) { | |
| loadingText.textContent = "Error: " + err.message; | |
| console.error(err); | |
| } | |
| } | |
| let FilesetResolver, PoseLandmarker, FaceLandmarker, HandLandmarker; | |
| async function initMediaPipe() { | |
| const vision = window.vision; | |
| FilesetResolver = vision.FilesetResolver; | |
| PoseLandmarker = vision.PoseLandmarker; | |
| FaceLandmarker = vision.FaceLandmarker; | |
| HandLandmarker = vision.HandLandmarker; | |
| const fileset = await FilesetResolver.forVisionTasks( | |
| "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.3/wasm" | |
| ); | |
| state.poseLandmarker = await PoseLandmarker.createFromOptions(fileset, { | |
| baseOptions: { | |
| modelAssetPath: "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task", | |
| delegate: "GPU" | |
| }, | |
| runningMode: "VIDEO", | |
| numPoses: 1 | |
| }); | |
| state.faceLandmarker = await FaceLandmarker.createFromOptions(fileset, { | |
| baseOptions: { | |
| modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker_with_blendshapes/float16/1/face_landmarker_with_blendshapes.task", | |
| delegate: "GPU" | |
| }, | |
| runningMode: "VIDEO", | |
| outputFaceBlendshapes: false, | |
| outputFacialTransformationMatrixes: false | |
| }); | |
| state.handLandmarker = await HandLandmarker.createFromOptions(fileset, { | |
| baseOptions: { | |
| modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task", | |
| delegate: "GPU" | |
| }, | |
| runningMode: "VIDEO", | |
| numHands: 2 | |
| }); | |
| nextPose(); | |
| } | |
| function connectWebSocket() { | |
| return new Promise((resolve, reject) => { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const wsUrl = `${protocol}//${window.location.host}/ws/${state.clientId}`; | |
| state.ws = new WebSocket(wsUrl); | |
| state.ws.onopen = () => { | |
| updateWSStatus(true); | |
| resolve(); | |
| }; | |
| state.ws.onmessage = (event) => { | |
| const msg = JSON.parse(event.data); | |
| if (msg.type === 'frame' && msg.mode === 'studio') { | |
| const img = new Image(); | |
| img.onload = () => { | |
| processedCtx.clearRect(0, 0, processedCanvas.width, processedCanvas.height); | |
| processedCtx.drawImage(img, 0, 0, processedCanvas.width, processedCanvas.height); | |
| }; | |
| img.src = 'data:image/jpeg;base64,' + msg.frame; | |
| } | |
| if (msg.type === 'rps_result') { | |
| showRPSResult(msg.player, msg.ai, msg.result); | |
| } | |
| }; | |
| state.ws.onclose = () => { | |
| updateWSStatus(false); | |
| setTimeout(() => connectWebSocket().catch(() => {}), 3000); | |
| }; | |
| state.ws.onerror = (e) => { | |
| updateWSStatus(false); | |
| reject(e); | |
| }; | |
| }); | |
| } | |
| function updateWSStatus(connected) { | |
| const el = document.getElementById('ws-status'); | |
| if (connected) { | |
| el.innerHTML = '<span class="dot green"></span>Connected'; | |
| } else { | |
| el.innerHTML = '<span class="dot red"></span>Disconnected'; | |
| } | |
| } | |
| function sendFrameToServer(frameB64) { | |
| if (state.ws && state.ws.readyState === WebSocket.OPEN) { | |
| state.ws.send(JSON.stringify({ action: 'frame', frame: frameB64 })); | |
| } | |
| } | |
| async function mainLoop() { | |
| const now = performance.now(); | |
| state.fps = Math.round(1000 / (now - state.lastFrameTime)); | |
| state.lastFrameTime = now; | |
| document.getElementById('fps-display').textContent = `FPS: ${state.fps || '--'}`; | |
| if (!video.videoWidth) { | |
| requestAnimationFrame(mainLoop); | |
| return; | |
| } | |
| const w = video.videoWidth; | |
| const h = video.videoHeight; | |
| if (processedCanvas.width !== w) { processedCanvas.width = w; processedCanvas.height = h; } | |
| if (arCanvas.width !== w) { arCanvas.width = w; arCanvas.height = h; } | |
| if (state.mode === 'studio') { | |
| const tmpCanvas = document.createElement('canvas'); | |
| tmpCanvas.width = w; tmpCanvas.height = h; | |
| const tmpCtx = tmpCanvas.getContext('2d'); | |
| tmpCtx.translate(w, 0); | |
| tmpCtx.scale(-1, 1); | |
| tmpCtx.drawImage(video, 0, 0); | |
| const frameB64 = tmpCanvas.toDataURL('image/jpeg', 0.7).split(',')[1]; | |
| sendFrameToServer(frameB64); | |
| if (state.ws?.readyState !== WebSocket.OPEN) { | |
| processedCtx.fillStyle = '#0d1117'; | |
| processedCtx.fillRect(0, 0, w, h); | |
| processedCtx.fillStyle = '#8b949e'; | |
| processedCtx.font = '24px sans-serif'; | |
| processedCtx.fillText('Connecting to style server...', w/2 - 140, h/2); | |
| } | |
| } | |
| else if (state.mode === 'pose') { | |
| processedCtx.save(); | |
| processedCtx.translate(w, 0); | |
| processedCtx.scale(-1, 1); | |
| processedCtx.drawImage(video, 0, 0); | |
| processedCtx.restore(); | |
| arCtx.clearRect(0, 0, w, h); | |
| if (state.poseLandmarker) { | |
| const results = state.poseLandmarker.detectForVideo(video, now); | |
| if (results.landmarks && results.landmarks.length > 0) { | |
| const lm = results.landmarks[0]; | |
| drawPoseSkeleton(arCtx, lm, w, h); | |
| drawTargetPose(arCtx, w, h); | |
| const sim = computePoseSimilarity(lm, state.poseTarget.keyPts, w, h); | |
| document.getElementById('pose-score').textContent = Math.round(sim * 100) + '%'; | |
| document.getElementById('pose-score').style.color = sim > 0.7 ? 'var(--success)' : sim > 0.4 ? 'var(--accent2)' : 'var(--danger)'; | |
| if (sim >= 0.75) { | |
| if (!state.poseHoldStart) state.poseHoldStart = Date.now(); | |
| const held = (Date.now() - state.poseHoldStart) / 1000; | |
| const remaining = Math.max(0, 2 - held); | |
| document.getElementById('pose-timer').textContent = remaining > 0 ? `HOLD! ${remaining.toFixed(1)}s` : '✅ SCORED!'; | |
| if (remaining <= 0) { state.poseScore += Math.round(sim * 100); nextPose(); } | |
| } else { state.poseHoldStart = null; document.getElementById('pose-timer').textContent = ''; } | |
| } else { | |
| document.getElementById('pose-score').textContent = '--'; | |
| document.getElementById('pose-timer').textContent = 'No body detected'; | |
| } | |
| } | |
| } | |
| else if (state.mode === 'face') { | |
| processedCtx.save(); | |
| processedCtx.translate(w, 0); | |
| processedCtx.scale(-1, 1); | |
| processedCtx.drawImage(video, 0, 0); | |
| processedCtx.restore(); | |
| arCtx.clearRect(0, 0, w, h); | |
| if (state.filter !== 'none' && state.faceLandmarker) { | |
| const results = state.faceLandmarker.detectForVideo(video, now); | |
| if (results.faceLandmarks && results.faceLandmarks.length > 0) { | |
| for (const fl of results.faceLandmarks) { applyFaceFilter(arCtx, fl, state.filter, w, h); } | |
| } | |
| } | |
| } | |
| else if (state.mode === 'painter') { | |
| processedCtx.save(); | |
| processedCtx.translate(w, 0); | |
| processedCtx.scale(-1, 1); | |
| processedCtx.drawImage(video, 0, 0); | |
| processedCtx.restore(); | |
| arCtx.clearRect(0, 0, w, h); | |
| if (!state.paintCanvas) initPaintCanvas(w, h); | |
| arCtx.drawImage(state.paintCanvas, 0, 0); | |
| if (state.handLandmarker) { | |
| const results = state.handLandmarker.detectForVideo(video, now); | |
| if (results.landmarks && results.landmarks.length > 0) { | |
| for (let i = 0; i < results.landmarks.length; i++) { | |
| const lm = results.landmarks[i]; | |
| const gesture = classifyHandGesture(lm); | |
| drawHandSkeleton(arCtx, lm, gesture, w, h); | |
| const tipX = (1 - lm[8].x) * w; | |
| const tipY = lm[8].y * h; | |
| if (gesture === 'POINTING' || gesture === 'PEACE') { | |
| arCtx.beginPath(); arCtx.arc(tipX, tipY, 4, 0, Math.PI * 2); arCtx.fillStyle = state.paintColor; arCtx.fill(); | |
| state.paintCtx.beginPath(); state.paintCtx.arc(tipX, tipY, 3, 0, Math.PI * 2); state.paintCtx.fillStyle = state.paintColor; state.paintCtx.fill(); | |
| } | |
| if (gesture === 'OPEN_PALM') { | |
| if (!state._lastColorChange || now - state._lastColorChange > 1000) { | |
| state.paintColorIdx = (state.paintColorIdx + 1) % state.paintColors.length; | |
| state.paintColor = state.paintColors[state.paintColorIdx]; | |
| state._lastColorChange = now; | |
| showToast(`Color changed to ${state.paintColor}`); | |
| } | |
| } | |
| if (gesture === 'FIST') { | |
| state.paintCtx.beginPath(); state.paintCtx.arc(tipX, tipY, 20, 0, Math.PI * 2); state.paintCtx.fillStyle = 'rgba(0,0,0,0.3)'; state.paintCtx.fill(); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| else if (state.mode === 'rps') { | |
| processedCtx.save(); | |
| processedCtx.translate(w, 0); | |
| processedCtx.scale(-1, 1); | |
| processedCtx.drawImage(video, 0, 0); | |
| processedCtx.restore(); | |
| arCtx.clearRect(0, 0, w, h); | |
| if (state.handLandmarker && state.rpsState === 'idle') { | |
| const results = state.handLandmarker.detectForVideo(video, now); | |
| if (results.landmarks && results.landmarks.length > 0) { | |
| const detectedGesture = classifyHandGesture(results.landmarks[0]); | |
| const rpsMove = gestureToRPS(detectedGesture); | |
| if (rpsMove) { | |
| arCtx.font = 'bold 24px sans-serif'; | |
| arCtx.fillStyle = 'var(--accent)'; | |
| arCtx.fillText(`Detected: ${rpsMove}`, 20, h - 20); | |
| drawHandSkeleton(arCtx, results.landmarks[0], detectedGesture, w, h); | |
| } | |
| } | |
| } | |
| } | |
| requestAnimationFrame(mainLoop); | |
| } | |
| function classifyHandGesture(lm) { | |
| const fingers = []; | |
| fingers.push(Math.abs(lm[4].x - lm[2].x) > 0.03); | |
| fingers.push(lm[8].y < lm[6].y); | |
| fingers.push(lm[12].y < lm[10].y); | |
| fingers.push(lm[16].y < lm[14].y); | |
| fingers.push(lm[20].y < lm[18].y); | |
| const count = fingers.filter(Boolean).length; | |
| const [thumb, index, middle, ring, pinky] = fingers; | |
| if (count === 0) return 'FIST'; | |
| if (count === 5) return 'OPEN_PALM'; | |
| if (index && !middle && !ring && !pinky) return 'POINTING'; | |
| if (index && middle && !ring && !pinky) return 'PEACE'; | |
| if (index && middle && ring && !pinky) return 'THREE'; | |
| if (thumb && !index && !middle && !ring && !pinky) return 'THUMBS_UP'; | |
| return `CUSTOM(${count})`; | |
| } | |
| function gestureToRPS(gesture) { | |
| if (gesture === 'FIST') return 'Rock'; | |
| if (gesture === 'OPEN_PALM') return 'Paper'; | |
| if (gesture === 'PEACE') return 'Scissors'; | |
| return null; | |
| } | |
| function nextPose() { | |
| state.poseHoldStart = null; | |
| state.poseTarget = state.poseTargets[Math.floor(Math.random() * state.poseTargets.length)]; | |
| document.getElementById('pose-target').textContent = `${state.poseTarget.name}: ${state.poseTarget.desc}`; | |
| } | |
| function computePoseSimilarity(lm, target, w, h) { | |
| let totalSim = 0; let count = 0; | |
| for (const [idx, [tx, ty]] of Object.entries(target)) { | |
| const i = parseInt(idx); | |
| if (i < lm.length && lm[i].visibility > 0.5) { | |
| const dx = (1 - lm[i].x) - tx; | |
| const dy = lm[i].y - ty; | |
| const dist = Math.sqrt(dx*dx + dy*dy); | |
| totalSim += Math.max(0, 1 - dist * 3); | |
| count++; | |
| } | |
| } | |
| return count > 0 ? totalSim / count : 0; | |
| } | |
| function drawPoseSkeleton(ctx, lm, w, h) { | |
| ctx.strokeStyle = 'rgba(0, 255, 136, 0.8)'; ctx.lineWidth = 3; | |
| for (const [a, b] of POSE_CONNECTIONS) { | |
| if (a < lm.length && b < lm.length && lm[a].visibility > 0.3 && lm[b].visibility > 0.3) { | |
| ctx.beginPath(); ctx.moveTo((1 - lm[a].x) * w, lm[a].y * h); ctx.lineTo((1 - lm[b].x) * w, lm[b].y * h); ctx.stroke(); | |
| } | |
| } | |
| for (const p of lm) { | |
| if (p.visibility > 0.3) { ctx.beginPath(); ctx.arc((1 - p.x) * w, p.y * h, 4, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,255,136,0.9)'; ctx.fill(); } | |
| } | |
| } | |
| function drawTargetPose(ctx, w, h) { | |
| const target = state.poseTarget; if (!target) return; | |
| ctx.strokeStyle = 'rgba(255, 136, 62, 0.6)'; ctx.lineWidth = 2; | |
| const points = {}; | |
| for (const [idx, [tx, ty]] of Object.entries(target.keyPts)) { | |
| const px = (1 - tx) * w; const py = ty * h; | |
| points[idx] = [px, py]; ctx.beginPath(); ctx.arc(px, py, 6, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,136,62,0.4)'; ctx.fill(); | |
| } | |
| for (const [a, b] of POSE_CONNECTIONS) { | |
| if (points[a] && points[b]) { ctx.beginPath(); ctx.moveTo(points[a][0], points[a][1]); ctx.lineTo(points[b][0], points[b][1]); ctx.stroke(); } | |
| } | |
| } | |
| function applyFaceFilter(ctx, lm, filter, w, h) { | |
| if (filter === 'glasses') { | |
| for (const eyePts of [[33, 133, 362, 263], [362, 263, 33, 133]]) { | |
| const pts = eyePts.map(i => [lm[i].x * w, lm[i].y * h]); | |
| const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length; | |
| const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length; | |
| const rx = Math.abs(pts[0][0] - pts[2][0]) / 2 + 10; | |
| const ry = Math.abs(pts[0][1] - pts[2][1]) / 2 + 8; | |
| ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(0,200,255,0.9)'; ctx.lineWidth = 3; ctx.stroke(); | |
| } | |
| const lcx = (lm[33].x + lm[133].x) / 2 * w; const rcx = (lm[362].x + lm[263].x) / 2 * w; | |
| const ly = (lm[33].y + lm[133].y) / 2 * h; const ry = (lm[362].y + lm[263].y) / 2 * h; | |
| ctx.beginPath(); ctx.moveTo(lcx, ly); ctx.lineTo(rcx, ry); ctx.strokeStyle = 'rgba(0,200,255,0.9)'; ctx.lineWidth = 3; ctx.stroke(); | |
| } | |
| else if (filter === 'cat') { | |
| const nose = [lm[1].x * w, lm[1].y * h]; | |
| ctx.beginPath(); ctx.arc(nose[0], nose[1], 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,100,100,0.9)'; ctx.fill(); | |
| ctx.beginPath(); ctx.moveTo(nose[0] - 12, nose[1] + 5); ctx.lineTo(nose[0], nose[1]); ctx.lineTo(nose[0] + 12, nose[1] + 5); ctx.strokeStyle = 'rgba(255,100,100,0.9)'; ctx.lineWidth = 2; ctx.stroke(); | |
| for (const side of [-1, 1]) { | |
| const ex = nose[0] + side * 80; const ey = nose[1] - 60; | |
| ctx.beginPath(); ctx.moveTo(ex, ey - 40); ctx.lineTo(ex - 25 * side, ey + 10); ctx.lineTo(ex + 15 * side, ey + 10); ctx.closePath(); ctx.fillStyle = 'rgba(255,100,100,0.8)'; ctx.fill(); | |
| } | |
| } | |
| else if (filter === 'crown') { | |
| const top = [lm[10].x * w, lm[10].y * h]; | |
| const pts = [[top[0] - 60, top[1] + 10], [top[0] - 30, top[1] - 50], [top[0], top[1] - 20], [top[0] + 30, top[1] - 50], [top[0] + 60, top[1] + 10]]; | |
| ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); ctx.closePath(); ctx.fillStyle = 'rgba(255,215,0,0.85)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,180,0,0.9)'; ctx.lineWidth = 2; ctx.stroke(); | |
| for (let i = 1; i < pts.length - 1; i += 2) { ctx.beginPath(); ctx.arc(pts[i][0], pts[i][1] + 5, 5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,50,50,0.9)'; ctx.fill(); } | |
| } | |
| else if (filter === 'mustache') { | |
| const nose = [lm[2].x * w, lm[2].y * h]; | |
| const ml = [lm[61].x * w, lm[61].y * h]; const mr = [lm[291].x * w, lm[291].y * h]; | |
| ctx.beginPath(); ctx.ellipse((ml[0] + nose[0]) / 2, nose[1] + 15, 25, 12, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(30,30,30,0.9)'; ctx.fill(); | |
| ctx.beginPath(); ctx.ellipse((mr[0] + nose[0]) / 2, nose[1] + 15, 25, 12, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(30,30,30,0.9)'; ctx.fill(); | |
| } | |
| else if (filter === 'anime') { | |
| for (const eyeCenterIdx of [[33, 133], [362, 263]]) { | |
| const cx = ((lm[eyeCenterIdx[0]].x + lm[eyeCenterIdx[1]].x) / 2) * w; | |
| const cy = ((lm[eyeCenterIdx[0]].y + lm[eyeCenterIdx[1]].y) / 2) * h; | |
| const rx = Math.abs(lm[eyeCenterIdx[0]].x - lm[eyeCenterIdx[1]].x) * w / 2 + 12; | |
| const ry = rx * 1.3; | |
| ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.fill(); ctx.strokeStyle = 'rgba(80,60,40,0.9)'; ctx.lineWidth = 3; ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(cx, cy + ry * 0.1, rx * 0.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(100,60,200,0.9)'; ctx.fill(); | |
| ctx.beginPath(); ctx.arc(cx - rx * 0.3, cy - ry * 0.3, rx * 0.2, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.fill(); | |
| } | |
| } | |
| } | |
| function initPaintCanvas(w, h) { | |
| state.paintCanvas = document.createElement('canvas'); state.paintCanvas.width = w; state.paintCanvas.height = h; | |
| state.paintCtx = state.paintCanvas.getContext('2d'); state.paintCtx.fillStyle = 'rgba(0,0,0,0)'; state.paintCtx.fillRect(0, 0, w, h); | |
| } | |
| function clearCanvas() { if (state.paintCtx) state.paintCtx.clearRect(0, 0, state.paintCanvas.width, state.paintCanvas.height); } | |
| function saveDrawing() { | |
| if (!state.paintCanvas) return; | |
| const link = document.createElement('a'); link.download = 'museum-painting-' + Date.now() + '.png'; link.href = state.paintCanvas.toDataURL(); link.click(); showToast('Drawing saved!'); | |
| } | |
| function drawHandSkeleton(ctx, lm, gesture, w, h) { | |
| const connections = [[0,1],[1,2],[2,3],[3,4],[0,5],[5,6],[6,7],[7,8],[0,9],[9,10],[10,11],[11,12],[0,13],[13,14],[14,15],[15,16],[0,17],[17,18],[18,19],[19,20]]; | |
| ctx.strokeStyle = gesture === 'FIST' ? 'rgba(248,81,73,0.7)' : gesture === 'OPEN_PALM' ? 'rgba(63,185,80,0.7)' : 'rgba(88,166,255,0.7)'; ctx.lineWidth = 2; | |
| for (const [a, b] of connections) { ctx.beginPath(); ctx.moveTo((1 - lm[a].x) * w, lm[a].y * h); ctx.lineTo((1 - lm[b].x) * w, lm[b].y * h); ctx.stroke(); } | |
| for (const p of lm) { ctx.beginPath(); ctx.arc((1 - p.x) * w, p.y * h, 3, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.fill(); } | |
| const tip = lm[9]; ctx.font = 'bold 16px sans-serif'; ctx.fillStyle = 'var(--accent)'; ctx.fillText(gesture, (1 - tip.x) * w - 30, tip.y * h - 20); | |
| } | |
| async function startRPSRound() { | |
| if (state.rpsState !== 'idle') return; | |
| state.rpsState = 'countdown'; | |
| document.getElementById('rps-start-btn').disabled = true; | |
| document.getElementById('rps-result').style.display = 'none'; | |
| for (let i = 3; i > 0; i--) { showCountdown(i); await sleep(1000); } | |
| showCountdown('SHOOT!'); await sleep(500); hideCountdown(); | |
| let playerMove = null; | |
| if (state.handLandmarker) { | |
| const results = state.handLandmarker.detectForVideo(video, performance.now()); | |
| if (results.landmarks && results.landmarks.length > 0) { playerMove = gestureToRPS(classifyHandGesture(results.landmarks[0])); } | |
| } | |
| if (!playerMove) playerMove = 'Rock'; | |
| if (state.ws && state.ws.readyState === WebSocket.OPEN) { | |
| state.ws.send(JSON.stringify({ action: 'rps_play', move: playerMove })); | |
| } else { | |
| const aiMove = ['Rock', 'Paper', 'Scissors'][Math.floor(Math.random() * 3)]; | |
| const beats = { Rock: 'Scissors', Paper: 'Rock', Scissors: 'Paper' }; | |
| let result = 'Draw!'; | |
| if (playerMove !== aiMove) result = beats[playerMove] === aiMove ? 'You Win!' : 'AI Wins!'; | |
| showRPSResult(playerMove, aiMove, result); | |
| } | |
| document.getElementById('rps-start-btn').disabled = false; | |
| state.rpsState = 'idle'; | |
| } | |
| function showRPSResult(player, ai, result) { | |
| const emojis = { Rock: '✊', Paper: '✋', Scissors: '✌️' }; | |
| const resultEl = document.getElementById('rps-result'); | |
| resultEl.innerHTML = `<b>You:</b> ${emojis[player]} ${player}<br><b>AI:</b> ${emojis[ai]} ${ai}<br><br><b style="font-size:1.3rem;color:${result.includes('You') ? 'var(--success)' : result.includes('AI') ? 'var(--danger)' : 'var(--accent2)'}">${result}</b>`; | |
| resultEl.style.display = 'block'; | |
| if (result.includes('You')) state.rpsScore.player++; | |
| else if (result.includes('AI')) state.rpsScore.ai++; | |
| document.getElementById('rps-player-score').textContent = state.rpsScore.player; | |
| document.getElementById('rps-ai-score').textContent = state.rpsScore.ai; | |
| } | |
| function setMode(mode) { | |
| state.mode = mode; | |
| document.querySelectorAll('.mode-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.mode === mode); }); | |
| document.querySelectorAll('.style-group, .pose-group, .filter-group, .painter-group, .rps-group').forEach(el => { el.classList.remove('visible'); }); | |
| if (mode === 'studio') document.getElementById('style-group').classList.add('visible'); | |
| if (mode === 'pose') document.getElementById('pose-group').classList.add('visible'); | |
| if (mode === 'face') document.getElementById('filter-group').classList.add('visible'); | |
| if (mode === 'painter') document.getElementById('painter-group').classList.add('visible'); | |
| if (mode === 'rps') document.getElementById('rps-group').classList.add('visible'); | |
| const modeNames = { studio: '🎨 Anime Studio', pose: '🧘 Pose Challenge', face: '😎 Face Filters', painter: '✋ Hand Painter', rps: '✊ Rock-Paper-Scissors' }; | |
| document.getElementById('mode-display').textContent = modeNames[mode] + (mode === 'studio' ? ` — ${MODELS[state.style]?.name || ''}` : ''); | |
| } | |
| function setStyle(style) { | |
| state.style = style; | |
| document.querySelectorAll('.style-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.style === style); }); | |
| document.getElementById('mode-display').textContent = `🎨 Anime Studio — ${MODELS[style].name}`; | |
| if (state.ws && state.ws.readyState === WebSocket.OPEN) state.ws.send(JSON.stringify({ action: 'set_style', style })); | |
| } | |
| function setFilter(filter) { | |
| state.filter = filter; | |
| document.querySelectorAll('.filter-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.filter === filter); }); | |
| } | |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| function showCountdown(text) { countdownText.textContent = text; countdownText.style.opacity = 1; } | |
| function hideCountdown() { countdownText.style.opacity = 0; } | |
| function showToast(msg) { | |
| const toast = document.createElement('div'); toast.textContent = msg; | |
| toast.style.cssText = 'position:fixed;bottom:20px;right:20px;background:var(--panel);color:var(--text);padding:12px 16px;border-radius:8px;border:1px solid var(--accent);font-size:0.9rem;z-index:200;transition:opacity 0.5s;'; | |
| document.body.appendChild(toast); | |
| setTimeout(() => { toast.style.opacity = 0; setTimeout(() => toast.remove(), 500); }, 2000); | |
| } | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === '1') setMode('studio'); | |
| if (e.key === '2') setMode('pose'); | |
| if (e.key === '3') setMode('face'); | |
| if (e.key === '4') setMode('painter'); | |
| if (e.key === '5') setMode('rps'); | |
| }); | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |