museum-ai-studio / static /index.html
Daniel-solo's picture
Upload static/index.html
2e4ba25 verified
<!DOCTYPE html>
<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>