Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Hand Control Particle System</title> | |
| <style> | |
| body { margin: 0; overflow: hidden; background-color: #050505; font-family: 'Segoe UI', sans-serif; } | |
| /* Video hidden, used for processing */ | |
| #input-video { display: none; } | |
| /* UI Overlay */ | |
| #ui-container { | |
| position: absolute; | |
| top: 20px; | |
| left: 20px; | |
| width: 280px; | |
| padding: 20px; | |
| background: rgba(20, 20, 20, 0.6); | |
| backdrop-filter: blur(10px); | |
| border-radius: 16px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| color: white; | |
| z-index: 10; | |
| box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5); | |
| transition: all 0.3s ease; | |
| } | |
| h1 { font-size: 1.2rem; margin: 0 0 15px 0; font-weight: 300; letter-spacing: 1px; } | |
| .control-group { margin-bottom: 15px; } | |
| label { display: block; font-size: 0.8rem; margin-bottom: 5px; color: #aaa; } | |
| select, input[type="color"] { | |
| width: 100%; | |
| padding: 8px; | |
| border-radius: 8px; | |
| border: none; | |
| background: rgba(255,255,255,0.1); | |
| color: white; | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| select option { background: #222; } | |
| .status { | |
| font-size: 0.75rem; | |
| color: #00ff88; | |
| margin-top: 10px; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .dot { width: 8px; height: 8px; background: #00ff88; border-radius: 50%; box-shadow: 0 0 5px #00ff88;} | |
| .dot.inactive { background: #ff4444; box-shadow: 0 0 5px #ff4444; } | |
| /* Loading Overlay */ | |
| #loading { | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| transform: translate(-50%, -50%); | |
| color: white; | |
| font-size: 1.5rem; | |
| pointer-events: none; | |
| } | |
| </style> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script> | |
| </head> | |
| <body> | |
| <div id="loading">Initializing AI & Graphics...</div> | |
| <div id="ui-container"> | |
| <h1>Particle Controller</h1> | |
| <div class="control-group"> | |
| <label>Shape Template</label> | |
| <select id="shape-select"> | |
| <option value="heart">Love Heart</option> | |
| <option value="saturn">Saturn Ring</option> | |
| <option value="galaxy">Spiral Galaxy</option> | |
| <option value="fireworks">Fireworks</option> | |
| <option value="sphere">Quantum Sphere</option> | |
| </select> | |
| </div> | |
| <div class="control-group"> | |
| <label>Particle Color</label> | |
| <input type="color" id="color-picker" value="#00ffff"> | |
| </div> | |
| <div class="status"> | |
| <div id="status-dot" class="dot inactive"></div> | |
| <span id="status-text">Waiting for camera...</span> | |
| </div> | |
| <p style="font-size: 0.7rem; color: #666; margin-top: 15px;"> | |
| Instruction: Show both hands. Move hands apart to expand. Clench fists to vibrate particles. | |
| </p> | |
| </div> | |
| <video id="input-video"></video> | |
| <script> | |
| // --- 1. CONFIGURATION --- | |
| const PARTICLE_COUNT = 15000; | |
| const PARTICLE_SIZE = 0.04; | |
| // State | |
| const state = { | |
| shape: 'heart', | |
| color: new THREE.Color(0x00ffff), | |
| handDistance: 1, // Multiplier for expansion | |
| handTension: 0, // Multiplier for jitter | |
| targetPositions: new Float32Array(PARTICLE_COUNT * 3), | |
| }; | |
| // --- 2. THREE.JS SETUP --- | |
| const scene = new THREE.Scene(); | |
| scene.fog = new THREE.FogExp2(0x050505, 0.05); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.z = 8; | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| document.body.appendChild(renderer.domElement); | |
| // Particles | |
| const geometry = new THREE.BufferGeometry(); | |
| const positions = new Float32Array(PARTICLE_COUNT * 3); | |
| // Initialize random positions | |
| for(let i=0; i<PARTICLE_COUNT*3; i++) { | |
| positions[i] = (Math.random() - 0.5) * 20; | |
| } | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| const material = new THREE.PointsMaterial({ | |
| size: PARTICLE_SIZE, | |
| color: state.color, | |
| transparent: true, | |
| opacity: 0.8, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false | |
| }); | |
| const particles = new THREE.Points(geometry, material); | |
| scene.add(particles); | |
| // --- 3. SHAPE GENERATORS --- | |
| // Helper to map sphere coordinates | |
| function randomSpherePoint() { | |
| const u = Math.random(); | |
| const v = Math.random(); | |
| const theta = 2 * Math.PI * u; | |
| const phi = Math.acos(2 * v - 1); | |
| let r = 3; | |
| const x = r * Math.sin(phi) * Math.cos(theta); | |
| const y = r * Math.sin(phi) * Math.sin(theta); | |
| const z = r * Math.cos(phi); | |
| return {x, y, z}; | |
| } | |
| const generators = { | |
| heart: (i) => { | |
| const t = Math.random() * Math.PI * 2; | |
| const u = Math.random() * Math.PI * 2; // density distribution | |
| // Heart formula | |
| let x = 16 * Math.pow(Math.sin(t), 3); | |
| let y = 13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t); | |
| let z = (Math.random()-0.5) * 4; // Thickness | |
| // Scale down | |
| return { x: x * 0.2, y: y * 0.2, z: z }; | |
| }, | |
| sphere: (i) => { | |
| return randomSpherePoint(); | |
| }, | |
| saturn: (i) => { | |
| // 70% Ring, 30% Planet | |
| if (Math.random() > 0.3) { | |
| // Ring | |
| const angle = Math.random() * Math.PI * 2; | |
| const r = 4 + Math.random() * 2; | |
| return { | |
| x: Math.cos(angle) * r, | |
| y: (Math.random() - 0.5) * 0.2, | |
| z: Math.sin(angle) * r | |
| }; | |
| } else { | |
| // Planet | |
| const p = randomSpherePoint(); | |
| return { x: p.x * 0.6, y: p.y * 0.6, z: p.z * 0.6 }; | |
| } | |
| }, | |
| galaxy: (i) => { | |
| const branches = 3; | |
| const spin = i / PARTICLE_COUNT * branches; | |
| const radius = (i / PARTICLE_COUNT) * 6; | |
| const angle = spin * Math.PI * 2; | |
| const randomOffset = (Math.random() - 0.5); | |
| return { | |
| x: Math.cos(angle) * radius + randomOffset, | |
| y: (Math.random() - 0.5) * (1 - radius/7), // Thicker at center | |
| z: Math.sin(angle) * radius + randomOffset | |
| }; | |
| }, | |
| fireworks: (i) => { | |
| const p = randomSpherePoint(); | |
| const burst = Math.random() * 6; | |
| return { x: p.x * burst, y: p.y * burst, z: p.z * burst }; | |
| } | |
| }; | |
| function updateTargetShape() { | |
| const generator = generators[state.shape]; | |
| for (let i = 0; i < PARTICLE_COUNT; i++) { | |
| const pos = generator(i); | |
| state.targetPositions[i * 3] = pos.x; | |
| state.targetPositions[i * 3 + 1] = pos.y; | |
| state.targetPositions[i * 3 + 2] = pos.z; | |
| } | |
| } | |
| // Initialize shape | |
| updateTargetShape(); | |
| // --- 4. MEDIAPIPE HAND TRACKING --- | |
| const videoElement = document.getElementById('input-video'); | |
| function onResults(results) { | |
| document.getElementById('loading').style.display = 'none'; | |
| const statusDot = document.getElementById('status-dot'); | |
| const statusText = document.getElementById('status-text'); | |
| if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { | |
| statusDot.classList.remove('inactive'); | |
| statusText.innerText = "Tracking Active"; | |
| // 1. Detect Expansion (Distance between two wrists if 2 hands present) | |
| if (results.multiHandLandmarks.length === 2) { | |
| const hand1 = results.multiHandLandmarks[0][0]; // Wrist | |
| const hand2 = results.multiHandLandmarks[1][0]; // Wrist | |
| // Calculate simple Euclidean distance in screen space | |
| const dx = hand1.x - hand2.x; | |
| const dy = hand1.y - hand2.y; | |
| const dist = Math.sqrt(dx*dx + dy*dy); | |
| // Map distance: 0.2 is close, 0.8 is far. Map to scale 0.5 to 2.0 | |
| state.handDistance = THREE.MathUtils.mapLinear(dist, 0.1, 0.8, 0.5, 2.5); | |
| } else { | |
| // Default if 1 hand | |
| state.handDistance = 1; | |
| } | |
| // 2. Detect Tension (Closed Hand) | |
| // Measure distance between Wrist (0) and Middle Finger Tip (12) | |
| let totalOpenness = 0; | |
| results.multiHandLandmarks.forEach(landmarks => { | |
| const wrist = landmarks[0]; | |
| const tip = landmarks[12]; | |
| const d = Math.sqrt(Math.pow(wrist.x - tip.x, 2) + Math.pow(wrist.y - tip.y, 2)); | |
| totalOpenness += d; | |
| }); | |
| // Normalize openness approximately (0.1 is fist, 0.3+ is open) | |
| const avgOpenness = totalOpenness / results.multiHandLandmarks.length; | |
| // If openness is low (fist), tension is high | |
| if (avgOpenness < 0.15) { | |
| state.handTension = THREE.MathUtils.lerp(state.handTension, 1.0, 0.1); | |
| } else { | |
| state.handTension = THREE.MathUtils.lerp(state.handTension, 0.0, 0.1); | |
| } | |
| } else { | |
| statusDot.classList.add('inactive'); | |
| statusText.innerText = "No Hands Detected"; | |
| state.handDistance = THREE.MathUtils.lerp(state.handDistance, 1, 0.05); | |
| state.handTension = THREE.MathUtils.lerp(state.handTension, 0, 0.05); | |
| } | |
| } | |
| const hands = new Hands({locateFile: (file) => { | |
| return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`; | |
| }}); | |
| hands.setOptions({ | |
| maxNumHands: 2, | |
| modelComplexity: 1, | |
| minDetectionConfidence: 0.5, | |
| minTrackingConfidence: 0.5 | |
| }); | |
| hands.onResults(onResults); | |
| // Initialize Camera | |
| const cameraUtils = new Camera(videoElement, { | |
| onFrame: async () => { | |
| await hands.send({image: videoElement}); | |
| }, | |
| width: 640, | |
| height: 480 | |
| }); | |
| cameraUtils.start(); | |
| // --- 5. UI INTERACTION --- | |
| document.getElementById('shape-select').addEventListener('change', (e) => { | |
| state.shape = e.target.value; | |
| updateTargetShape(); | |
| }); | |
| document.getElementById('color-picker').addEventListener('input', (e) => { | |
| material.color.set(e.target.value); | |
| }); | |
| // --- 6. ANIMATION LOOP --- | |
| const clock = new THREE.Clock(); | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const time = clock.getElapsedTime(); | |
| const positions = particles.geometry.attributes.position.array; | |
| // Interaction smoothing | |
| const targetScale = state.handDistance; | |
| const jitterIntensity = state.handTension * 0.2; // How much they shake when fist is closed | |
| for (let i = 0; i < PARTICLE_COUNT; i++) { | |
| const ix = i * 3; | |
| const iy = i * 3 + 1; | |
| const iz = i * 3 + 2; | |
| // Get target base position | |
| let tx = state.targetPositions[ix]; | |
| let ty = state.targetPositions[iy]; | |
| let tz = state.targetPositions[iz]; | |
| // Apply Scale (Hand Distance) | |
| tx *= targetScale; | |
| ty *= targetScale; | |
| tz *= targetScale; | |
| // Apply Jitter (Hand Tension) | |
| if (state.handTension > 0.1) { | |
| tx += (Math.random() - 0.5) * jitterIntensity; | |
| ty += (Math.random() - 0.5) * jitterIntensity; | |
| tz += (Math.random() - 0.5) * jitterIntensity; | |
| } | |
| // Simple Lerp for smooth transition | |
| positions[ix] += (tx - positions[ix]) * 0.05; | |
| positions[iy] += (ty - positions[iy]) * 0.05; | |
| positions[iz] += (tz - positions[iz]) * 0.05; | |
| } | |
| // Slight rotation for the whole system | |
| particles.rotation.y = time * 0.1; | |
| // Dynamic Wave Effect if idle | |
| if (state.handTension < 0.1) { | |
| // particles.rotation.z = Math.sin(time * 0.5) * 0.1; | |
| } | |
| particles.geometry.attributes.position.needsUpdate = true; | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| // Resize Handler | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |