fredmo's picture
Update index.html
f9c7ade verified
<!DOCTYPE html>
<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/[email protected]";
// 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/[email protected]/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>