Spaces:
Running
Running
| <html> | |
| <head> | |
| <title>✨ K-pop Dance Challenge ✨</title> | |
| <meta charset="utf-8"> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --pink: #ffafcc; | |
| --blue: #a2d2ff; | |
| --purple: #cdb4db; | |
| --white: #ffffff; | |
| --dark-text: #333; | |
| } | |
| body { | |
| font-family: 'Nunito', sans-serif; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| height: 100vh; | |
| margin: 0; | |
| padding-bottom: 50px; /* Add padding to prevent footer from overlapping content */ | |
| background: linear-gradient(45deg, var(--pink), var(--blue)); | |
| color: var(--dark-text); | |
| box-sizing: border-box; | |
| } | |
| .container { | |
| text-align: center; | |
| background: rgba(255, 255, 255, 0.7); | |
| padding: 40px; | |
| border-radius: 20px; | |
| box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37); | |
| backdrop-filter: blur(4px); | |
| border: 1px solid rgba(255, 255, 255, 0.18); | |
| } | |
| #song-selection, #game, #result { | |
| display: none; | |
| } | |
| h1 { | |
| font-size: 2.5em; | |
| line-height: 1.2; | |
| } | |
| #video-container { | |
| position: relative; | |
| width: 640px; | |
| height: 480px; | |
| margin: 0 auto; | |
| border: 4px solid var(--white); | |
| border-radius: 20px; | |
| overflow: hidden; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.2); | |
| } | |
| #video { | |
| width: 100%; | |
| height: 100%; | |
| transform: scaleX(-1); /* Mirror effect */ | |
| } | |
| #output_canvas { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| transform: scaleX(-1); /* Match the video flip */ | |
| } | |
| .overlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| color: var(--white); | |
| font-size: 80px; | |
| font-weight: 700; | |
| background-color: rgba(0, 0, 0, 0.4); | |
| flex-direction: column; | |
| z-index: 10; | |
| text-shadow: 2px 2px 8px rgba(0,0,0,0.7); | |
| } | |
| .dancer-name { | |
| position: absolute; | |
| top: 20px; | |
| left: 20px; | |
| background-color: var(--purple); | |
| color: var(--white); | |
| padding: 10px 20px; | |
| border-radius: 15px; | |
| font-size: 24px; | |
| z-index: 5; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.2); | |
| } | |
| .controls { | |
| margin-top: 20px; | |
| } | |
| button { | |
| padding: 15px 30px; | |
| font-family: 'Nunito', sans-serif; | |
| font-size: 18px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| margin: 10px; | |
| border-radius: 50px; | |
| border: none; | |
| background-color: var(--pink); | |
| color: var(--white); | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; | |
| box-shadow: 0 4px 10px rgba(0,0,0,0.1); | |
| } | |
| button:hover { | |
| transform: translateY(-3px); | |
| box-shadow: 0 6px 15px rgba(0,0,0,0.2); | |
| } | |
| #toggle-skeleton, #restart-button { | |
| background-color: var(--blue); | |
| } | |
| #next-dancer { | |
| background-color: var(--purple); | |
| } | |
| #result h1 { | |
| font-size: 3em; | |
| margin-bottom: 20px; | |
| } | |
| footer { | |
| position: fixed; | |
| bottom: 0; | |
| left: 0; | |
| width: 100%; | |
| padding: 15px; | |
| background-color: rgba(0, 0, 0, 0.1); | |
| color: var(--white); | |
| text-align: center; | |
| font-size: 14px; | |
| z-index: 100; | |
| text-shadow: 1px 1px 2px rgba(0,0,0,0.5); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app-container"> | |
| <div id="song-selection" class="container"> | |
| <h1>✨🍨 K AI Pop Dance - Challenge your Friend 💖✨</h1> | |
| <p>Choose your battle track!</p> | |
| <button id="song-aespa">Aespa - Dirty Work</button> | |
| <button id="song-itzy">Itzy - girls will be girl</button> | |
| <button id="song-blackpink">BlackPink - Jump</button> | |
| </div> | |
| <div id="game" class="container"> | |
| <div id="video-container"> | |
| <video id="video" autoplay playsinline></video> | |
| <canvas id="output_canvas"></canvas> | |
| <div id="countdown" class="overlay" style="display: none;"></div> | |
| <div id="dancer-name" class="dancer-name"></div> | |
| </div> | |
| <div class="controls"> | |
| <button id="toggle-skeleton">Show Skeleton</button> | |
| <button id="next-dancer" style="display: none;">Next Dancer! ✨</button> | |
| </div> | |
| </div> | |
| <div id="result" class="container"> | |
| <h1>And the result is...</h1> | |
| <h1 id="score"></h1> | |
| <button id="restart-button">Play Again? 💖</button> | |
| </div> | |
| </div> | |
| <footer> | |
| Fredmo - vibe coded with gemini 2.5 pro - 2025 - All rights reserved to their respective owners | |
| </footer> | |
| <audio id="audio-player"></audio> | |
| <script type="module"> | |
| import { PoseLandmarker, FilesetResolver, DrawingUtils } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0"; | |
| // DOM Elements | |
| const video = document.getElementById('video'); | |
| const canvasElement = document.getElementById('output_canvas'); | |
| const canvasCtx = canvasElement.getContext('2d'); | |
| const drawingUtils = new DrawingUtils(canvasCtx); | |
| const songSelectionScreen = document.getElementById('song-selection'); | |
| const gameScreen = document.getElementById('game'); | |
| const resultScreen = document.getElementById('result'); | |
| const countdownOverlay = document.getElementById('countdown'); | |
| const dancerNameDisplay = document.getElementById('dancer-name'); | |
| const nextDancerButton = document.getElementById('next-dancer'); | |
| const toggleSkeletonButton = document.getElementById('toggle-skeleton'); | |
| const scoreDisplay = document.getElementById('score'); | |
| const audioPlayer = document.getElementById('audio-player'); | |
| const restartButton = document.getElementById('restart-button'); | |
| // Game State | |
| let poseLandmarker; | |
| let player1Data = []; | |
| let player2Data = []; | |
| let currentSong = ''; | |
| let isPlayer1 = true; | |
| let captureData = false; | |
| let showSkeleton = false; | |
| let frameCounter = 0; | |
| const CAPTURE_INTERVAL = 5; // Capture every 5 frames | |
| const songFiles = { | |
| aespa: 'aespa.mp3', | |
| itzy: 'itzy.mp3', | |
| blackpink: 'blackpink.mp3' | |
| }; | |
| const dancerNameMap = { | |
| aespa: { p1: 'My 1 🦋', p2: 'My 2 🦋' }, | |
| itzy: { p1: 'Midzy 1 👑', p2: 'Midzy 2 👑' }, | |
| blackpink: { p1: 'Blink 1 🖤', p2: 'Blink 2 💖' } | |
| }; | |
| const POSE_SEGMENTS = [ | |
| ['left_shoulder', 'left_elbow'], ['left_elbow', 'left_wrist'], | |
| ['right_shoulder', 'right_elbow'], ['right_elbow', 'right_wrist'], | |
| ['left_hip', 'right_hip'], | |
| ['left_shoulder', 'left_hip'], ['right_shoulder', 'right_hip'], | |
| ['left_hip', 'left_knee'], ['left_knee', 'left_ankle'], | |
| ['right_hip', 'right_knee'], ['right_knee', 'right_ankle'] | |
| ]; | |
| let landmarkNameToIndex = {}; | |
| const POSE_LANDMARK_NAMES = [ | |
| 'nose', 'left_eye_inner', 'left_eye', 'left_eye_outer', 'right_eye_inner', 'right_eye', 'right_eye_outer', | |
| 'left_ear', 'right_ear', 'mouth_left', 'mouth_right', 'left_shoulder', 'right_shoulder', 'left_elbow', | |
| 'right_elbow', 'left_wrist', 'right_wrist', 'left_pinky', 'right_pinky', 'left_index', 'right_index', | |
| 'left_thumb', 'right_thumb', 'left_hip', 'right_hip', 'left_knee', 'right_knee', 'left_ankle', | |
| 'right_ankle', 'left_heel', 'right_heel', 'left_foot_index', 'right_foot_index' | |
| ]; | |
| async function main() { | |
| const filesetResolver = await FilesetResolver.forVisionTasks( | |
| "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.0/wasm" | |
| ); | |
| poseLandmarker = await PoseLandmarker.createFromOptions(filesetResolver, { | |
| 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 | |
| }); | |
| POSE_LANDMARK_NAMES.forEach((name, index) => { | |
| landmarkNameToIndex[name] = index; | |
| }); | |
| await initWebcam(); | |
| songSelectionScreen.style.display = 'block'; | |
| } | |
| async function initWebcam() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } }); | |
| video.srcObject = stream; | |
| video.addEventListener("loadeddata", predictWebcam); | |
| } catch (err) { | |
| console.error("Error accessing webcam: ", err); | |
| alert("Oops! Could not access webcam. Please allow access and reload the page. 💖"); | |
| } | |
| } | |
| let lastVideoTime = -1; | |
| function predictWebcam() { | |
| canvasElement.width = video.videoWidth; | |
| canvasElement.height = video.videoHeight; | |
| if (video.currentTime !== lastVideoTime) { | |
| lastVideoTime = video.currentTime; | |
| const poseLandmarkerResult = poseLandmarker.detectForVideo(video, performance.now()); | |
| canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height); | |
| if (showSkeleton && poseLandmarkerResult.landmarks.length > 0) { | |
| drawSkeleton(poseLandmarkerResult.landmarks[0]); | |
| } | |
| frameCounter++; | |
| if (captureData && frameCounter % CAPTURE_INTERVAL === 0 && poseLandmarkerResult.worldLandmarks.length > 0) { | |
| const poseVector = getPoseVector(poseLandmarkerResult.worldLandmarks[0]); | |
| if(poseVector){ | |
| if (isPlayer1) player1Data.push(poseVector); | |
| else player2Data.push(poseVector); | |
| } | |
| } | |
| } | |
| window.requestAnimationFrame(predictWebcam); | |
| } | |
| function drawSkeleton(landmarks) { | |
| drawingUtils.drawLandmarks(landmarks, { | |
| radius: 5, color: '#FFFFFF', fillColor: 'var(--pink)' | |
| }); | |
| drawingUtils.drawConnectors(landmarks, PoseLandmarker.POSE_CONNECTIONS, { color: 'var(--white)', lineWidth: 3 }); | |
| } | |
| function selectSong(song) { | |
| currentSong = song; | |
| audioPlayer.src = songFiles[song]; | |
| songSelectionScreen.style.display = 'none'; | |
| gameScreen.style.display = 'block'; | |
| startPlayerDance(); | |
| } | |
| function startPlayerDance() { | |
| const playerNames = dancerNameMap[currentSong]; | |
| dancerNameDisplay.textContent = isPlayer1 ? playerNames.p1 : playerNames.p2; | |
| runCountdown(startDanceSession); | |
| } | |
| function runCountdown(onComplete) { | |
| countdownOverlay.style.display = 'flex'; | |
| let count = 3; | |
| countdownOverlay.textContent = count; | |
| const interval = setInterval(() => { | |
| count--; | |
| if (count > 0) countdownOverlay.textContent = count; | |
| else if (count === 0) countdownOverlay.textContent = 'GO!'; | |
| else { | |
| clearInterval(interval); | |
| countdownOverlay.style.display = 'none'; | |
| onComplete(); | |
| } | |
| }, 1000); | |
| } | |
| function startDanceSession() { | |
| captureData = true; | |
| audioPlayer.currentTime = 0; | |
| audioPlayer.play(); | |
| setTimeout(() => { | |
| audioPlayer.pause(); | |
| captureData = false; | |
| if (isPlayer1) { | |
| isPlayer1 = false; | |
| nextDancerButton.style.display = 'inline-block'; | |
| } else { | |
| calculateSimilarity(); | |
| } | |
| }, 8000); | |
| } | |
| function getPoseVector(worldLandmarks) { | |
| const vector = []; | |
| for (const [start, end] of POSE_SEGMENTS) { | |
| const startIdx = landmarkNameToIndex[start]; | |
| const endIdx = landmarkNameToIndex[end]; | |
| if(worldLandmarks[startIdx] && worldLandmarks[endIdx]){ | |
| const p1 = worldLandmarks[startIdx]; | |
| const p2 = worldLandmarks[endIdx]; | |
| const dx = p2.x - p1.x; | |
| const dy = p2.y - p1.y; | |
| const dz = p2.z - p1.z; | |
| const mag = Math.sqrt(dx*dx + dy*dy + dz*dz); | |
| if(mag === 0) continue; | |
| vector.push(dx / mag, dy / mag, dz / mag); | |
| } | |
| } | |
| return vector.length > 0 ? vector : null; | |
| } | |
| function cosineSimilarity(vecA, vecB) { | |
| let dotProduct = 0.0; | |
| let normA = 0.0; | |
| let normB = 0.0; | |
| for (let i = 0; i < vecA.length; i++) { | |
| dotProduct += vecA[i] * vecB[i]; | |
| normA += vecA[i] * vecA[i]; | |
| normB += vecB[i] * vecB[i]; | |
| } | |
| if (normA === 0 || normB === 0) return 0; | |
| return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); | |
| } | |
| function calculateSimilarity() { | |
| gameScreen.style.display = 'none'; | |
| resultScreen.style.display = 'block'; | |
| showSkeleton = false; | |
| let totalSimilarity = 0; | |
| const frameCount = Math.min(player1Data.length, player2Data.length); | |
| if (frameCount < 5) { | |
| scoreDisplay.textContent = "Not enough data! 😭 Try dancing more clearly!"; | |
| return; | |
| } | |
| for (let i = 0; i < frameCount; i++) { | |
| totalSimilarity += cosineSimilarity(player1Data[i], player2Data[i]); | |
| } | |
| const avgSimilarity = totalSimilarity / frameCount; | |
| const scaledScore = Math.pow(avgSimilarity, 2); | |
| const percentage = Math.min(100, Math.round(scaledScore * 100)); | |
| scoreDisplay.textContent = `${percentage}% Similarity! Great job! 🎉`; | |
| } | |
| function restartGame() { | |
| location.reload(); | |
| } | |
| // Event Listeners | |
| document.getElementById('song-aespa').addEventListener('click', () => selectSong('aespa')); | |
| document.getElementById('song-itzy').addEventListener('click', () => selectSong('itzy')); | |
| document.getElementById('song-blackpink').addEventListener('click', () => selectSong('blackpink')); | |
| nextDancerButton.addEventListener('click', () => { | |
| nextDancerButton.style.display = 'none'; | |
| startPlayerDance(); | |
| }); | |
| toggleSkeletonButton.addEventListener('click', () => { | |
| showSkeleton = !showSkeleton; | |
| toggleSkeletonButton.textContent = showSkeleton ? 'Hide Skeleton' : 'Show Skeleton'; | |
| }); | |
| restartButton.addEventListener('click', restartGame); | |
| // Start the application | |
| main(); | |
| </script> | |
| </body> | |
| </html> |