Spaces:
Running
Running
| <html lang="ko"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> | |
| <title>Cute Hamster Mirror ๐น</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <style> | |
| body, html, #root { | |
| width: 100%; | |
| height: 100%; | |
| margin: 0; | |
| padding: 0; | |
| overflow: hidden; | |
| background-color: #FEF3C7; /* ๋ฐ๋ปํ ๋ฐฐ๊ฒฝ */ | |
| font-family: 'Segoe UI', sans-serif; | |
| touch-action: none; | |
| } | |
| .loader { | |
| width: 48px; | |
| height: 48px; | |
| border: 5px solid #FFF; | |
| border-bottom-color: #F59E0B; | |
| border-radius: 50%; | |
| display: inline-block; | |
| box-sizing: border-box; | |
| animation: rotation 1s linear infinite; | |
| } | |
| @keyframes rotation { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| </style> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "react": "https://esm.sh/react@18.3.1", | |
| "react-dom/client": "https://esm.sh/react-dom@18.3.1/client", | |
| "@mediapipe/tasks-vision": "https://esm.run/@mediapipe/tasks-vision@0.10.9" | |
| } | |
| } | |
| </script> | |
| </head> | |
| <body> | |
| <div id="root"></div> | |
| <script type="text/babel" data-type="module"> | |
| import React, { useState, useEffect, useRef } from 'react'; | |
| import ReactDOM from 'react-dom/client'; | |
| import { HandLandmarker, FilesetResolver } from '@mediapipe/tasks-vision'; | |
| // --- 1. ๋์์ธ ์์ (๊ท์ฌ์ด ๊ณจ๋ ํ์คํฐ) --- | |
| const COLORS = { | |
| furMain: "#FBBF24", // ๋ฐ์ ํฉ๊ธ์ (๋ชธํต) | |
| furShadow: "#D97706", // ๊ทธ๋ฆผ์ | |
| furWhite: "#FFFFFF", // ๋ฐฐ, ์ฃผ๋ฅ์ด | |
| earInner: "#FCA5A5", // ๊ท์ (ํํฌ) | |
| nose: "#FB7185", // ์ฝ (์งํ ํํฌ) | |
| cheek: "#FECACA", // ๋ณผํฐ์น (์ฐํ ํํฌ) | |
| pawPad: "#F472B6", // ๋ฐ๋ฐ๋ฅ ์ ค๋ฆฌ | |
| stroke: "#78350F" // ์ง์ ๊ฐ์ ๋ผ์ธ | |
| }; | |
| const lerp = (start, end, t) => start * (1 - t) + end * t; | |
| // --- 2. Hand Tracking Hook (๋์ผ ๋ก์ง ์ ์ง) --- | |
| const useHandTracking = () => { | |
| const videoRef = useRef(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState(null); | |
| const handDataRef = useRef({ left: null, right: null }); | |
| useEffect(() => { | |
| let landmarker = null; | |
| let animationFrameId = null; | |
| const startTracking = async () => { | |
| try { | |
| const vision = await FilesetResolver.forVisionTasks( | |
| "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/wasm" | |
| ); | |
| landmarker = await HandLandmarker.createFromOptions(vision, { | |
| baseOptions: { | |
| modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`, | |
| delegate: "GPU" | |
| }, | |
| runningMode: "VIDEO", | |
| numHands: 2, | |
| minHandDetectionConfidence: 0.5, | |
| minHandPresenceConfidence: 0.5, | |
| minTrackingConfidence: 0.5 | |
| }); | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } | |
| }); | |
| if (videoRef.current) { | |
| videoRef.current.srcObject = stream; | |
| videoRef.current.onloadeddata = () => { | |
| setLoading(false); | |
| predict(); | |
| }; | |
| } | |
| } catch (err) { | |
| setError("์นด๋ฉ๋ผ ๊ถํ์ด ํ์ํฉ๋๋ค."); | |
| setLoading(false); | |
| } | |
| }; | |
| const predict = () => { | |
| if (videoRef.current && videoRef.current.readyState >= 2 && landmarker) { | |
| const results = landmarker.detectForVideo(videoRef.current, performance.now()); | |
| let newLeft = null; | |
| let newRight = null; | |
| if (results.landmarks) { | |
| results.landmarks.forEach((landmarks, index) => { | |
| const handedness = results.handedness[index][0].categoryName; | |
| const processed = landmarks.map(p => ({ x: (1 - p.x), y: p.y })); | |
| if (handedness === 'Left') newLeft = processed; | |
| else newRight = processed; | |
| }); | |
| } | |
| const smooth = (prev, next) => { | |
| if (!prev) return next; | |
| if (!next) return null; | |
| return next.map((p, i) => ({ | |
| x: lerp(prev[i].x, p.x, 0.3), | |
| y: lerp(prev[i].y, p.y, 0.3) | |
| })); | |
| }; | |
| handDataRef.current = { | |
| left: smooth(handDataRef.current.left, newLeft), | |
| right: smooth(handDataRef.current.right, newRight) | |
| }; | |
| } | |
| animationFrameId = requestAnimationFrame(predict); | |
| }; | |
| startTracking(); | |
| return () => { | |
| if (animationFrameId) cancelAnimationFrame(animationFrameId); | |
| if (landmarker) landmarker.close(); | |
| }; | |
| }, []); | |
| return { videoRef, handDataRef, loading, error }; | |
| }; | |
| // --- 3. ํ์คํฐ ํ & ์ ค๋ฆฌ ๋ฐ๋ฐ๋ฅ (ํต์ฌ ๋ณ๊ฒฝ) --- | |
| const HamsterPaw = ({ landmarks, side, bodyAnchor }) => { | |
| // 1. ์์ด ์์ ๋: ํด๋ฐ๋ผ๊ธฐ์จ ๋จน๋ ํฌ์ฆ | |
| if (!landmarks) { | |
| const restX = side === 'left' ? bodyAnchor.x - 20 : bodyAnchor.x + 20; | |
| const restY = bodyAnchor.y + 40; | |
| const rotate = side === 'left' ? -20 : 20; | |
| return ( | |
| <g transform={`translate(${restX}, ${restY}) rotate(${rotate})`}> | |
| {/* ํ๋ */} | |
| <path d={`M ${side === 'left' ? 20 : -20} -40 Q 0 -20 0 0`} | |
| stroke={COLORS.furMain} strokeWidth="35" strokeLinecap="round" fill="none" /> | |
| {/* ์ฃผ๋จน ์ฅ ์ */} | |
| <circle r="18" fill={COLORS.furWhite} stroke="#E5E7EB" strokeWidth="1" /> | |
| <circle cx="-5" cy="-2" r="6" fill={COLORS.pawPad} /> | |
| <circle cx="5" cy="-2" r="6" fill={COLORS.pawPad} /> | |
| <circle cx="0" cy="6" r="8" fill={COLORS.pawPad} opacity="0.8" /> | |
| {/* ํด๋ฐ๋ผ๊ธฐ์จ (ํ์ชฝ์๋ง) */} | |
| {side === 'right' && ( | |
| <path d="M -15 -10 Q -25 0 -15 10 Q -5 0 -15 -10" fill="#4B5563" transform="rotate(-45)"/> | |
| )} | |
| </g> | |
| ); | |
| } | |
| // 2. ์์ด ์์ ๋ | |
| const scaleX = 800; | |
| const scaleY = 600; | |
| const wrist = { x: landmarks[0].x * scaleX, y: landmarks[0].y * scaleY }; | |
| const indexTip = { x: landmarks[8].x * scaleX, y: landmarks[8].y * scaleY }; | |
| // ํ ์ฐ๊ฒฐ ๊ณก์ (๋ ํตํตํ๊ฒ) | |
| const controlX = (bodyAnchor.x + wrist.x) / 2 + (side === 'left' ? -30 : 30); | |
| const controlY = Math.min(wrist.y, bodyAnchor.y) + 50; | |
| // ์๊ฐ๋ฝ ์์น์ ์ ค๋ฆฌ ๊ทธ๋ฆฌ๊ธฐ | |
| const renderToe = (idx, size=8) => { | |
| const p = landmarks[idx]; | |
| return <circle cx={p.x * scaleX} cy={p.y * scaleY} r={size} fill={COLORS.pawPad} />; | |
| }; | |
| return ( | |
| <g> | |
| {/* ํตํตํ ํ๋ */} | |
| <path | |
| d={`M ${bodyAnchor.x} ${bodyAnchor.y} Q ${controlX} ${controlY} ${wrist.x} ${wrist.y}`} | |
| stroke={COLORS.furMain} | |
| strokeWidth="50" | |
| strokeLinecap="round" | |
| fill="none" | |
| filter="url(#fluffy)" | |
| /> | |
| {/* ์๋ฑ (ํ์ ํธ) */} | |
| <circle cx={wrist.x} cy={wrist.y} r="25" fill={COLORS.furWhite} /> | |
| {/* ๋ฐ๋ฐ๋ฅ ์ ค๋ฆฌ (์๊ฐ๋ฝ ๋) */} | |
| {renderToe(4, 9)} {/* ์์ง */} | |
| {renderToe(8, 9)} {/* ๊ฒ์ง */} | |
| {renderToe(12, 10)} {/* ์ค์ง (๊ฐ์ฅ ํผ) */} | |
| {renderToe(16, 9)} {/* ์ฝ์ง */} | |
| {renderToe(20, 8)} {/* ์์ง */} | |
| {/* ์๋ฐ๋ฅ ์ค์ ์ ค๋ฆฌ (์๋ชฉ ๊ทผ์ฒ) */} | |
| <circle cx={wrist.x} cy={wrist.y + 5} r="18" fill={COLORS.pawPad} opacity="0.4" /> | |
| </g> | |
| ); | |
| }; | |
| // --- 4. ๋ฉ์ธ ํ์คํฐ (๋๊ธ๋๊ธ ๋ฒ์ ) --- | |
| const Hamster = ({ handData }) => { | |
| const [tick, setTick] = useState(0); | |
| const [blink, setBlink] = useState(false); | |
| const [eyeOffset, setEyeOffset] = useState({x:0, y:0}); | |
| useEffect(() => { | |
| const loop = () => setTick(t => t + 1); | |
| const id = setInterval(loop, 16); | |
| return () => clearInterval(id); | |
| }, []); | |
| // ๋ ๊น๋นก์ | |
| useEffect(() => { | |
| const timer = setInterval(() => { | |
| setBlink(true); | |
| setTimeout(() => setBlink(false), 150); | |
| }, 2500 + Math.random() * 2000); | |
| return () => clearInterval(timer); | |
| }, []); | |
| // ์์ ์ฒ๋ฆฌ | |
| useEffect(() => { | |
| if(handData.left || handData.right) { | |
| let tx = 0, ty = 0, c = 0; | |
| if(handData.left) { tx += handData.left[0].x; ty += handData.left[0].y; c++; } | |
| if(handData.right) { tx += handData.right[0].x; ty += handData.right[0].y; c++; } | |
| if(c > 0) { | |
| setEyeOffset({ | |
| x: (tx/c - 0.5) * 20, | |
| y: (ty/c - 0.5) * 15 | |
| }); | |
| } | |
| } | |
| }, [tick, handData]); | |
| // ์ ๋๋ฉ์ด์ ๋ณ์ | |
| const breath = 1 + Math.sin(tick * 0.05) * 0.02; // ์จ์ฌ๊ธฐ | |
| const noseWiggle = Math.sin(tick * 0.2) * 2; // ์ฝ ๋ฒ๋ฆ๊ฑฐ๋ฆผ | |
| return ( | |
| <svg viewBox="0 0 800 600" className="w-full h-full drop-shadow-2xl"> | |
| <defs> | |
| {/* ํธ ๋๋ ํํฐ (๋ถ๋๋ฝ๊ฒ) */} | |
| <filter id="fluffy" x="-20%" y="-20%" width="140%" height="140%"> | |
| <feTurbulence type="fractalNoise" baseFrequency="0.5" numOctaves="3" result="noise" /> | |
| <feColorMatrix type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -9" in="noise" result="coloredNoise" /> | |
| <feComposite operator="in" in="coloredNoise" in2="SourceGraphic" result="composite" /> | |
| <feGaussianBlur stdDeviation="1" /> | |
| </filter> | |
| <radialGradient id="gradBody" cx="0.5" cy="0.4" r="0.6"> | |
| <stop offset="0%" stopColor="#FCD34D" /> | |
| <stop offset="100%" stopColor="#F59E0B" /> | |
| </radialGradient> | |
| </defs> | |
| {/* ์ค์ ์ ๋ ฌ ๊ทธ๋ฃน */} | |
| <g transform={`translate(400, 360) scale(${breath})`}> | |
| {/* ๊ท (๋ฅ๊ธ๊ฒ) */} | |
| <g transform="translate(-100, -120)"> | |
| <circle r="40" fill={COLORS.furMain} /> | |
| <circle r="25" fill={COLORS.earInner} transform="translate(5, 5)" /> | |
| </g> | |
| <g transform="translate(100, -120)"> | |
| <circle r="40" fill={COLORS.furMain} /> | |
| <circle r="25" fill={COLORS.earInner} transform="translate(-5, 5)" /> | |
| </g> | |
| {/* ๋ชธํต (๊ฐ์ ๋ชจ์) */} | |
| <ellipse cy="20" rx="180" ry="200" fill="url(#gradBody)" /> | |
| {/* ํ์ ๋ฐฐ */} | |
| <path d="M -120 80 Q 0 220 120 80 Q 120 -50 0 -80 Q -120 -50 -120 80" fill={COLORS.furWhite} /> | |
| {/* ๋ฐ (๊ณ ์ ๋ ๋ท๋ฐ) */} | |
| <ellipse cx="-100" cy="190" rx="40" ry="25" fill={COLORS.furWhite} /> | |
| <ellipse cx="100" cy="190" rx="40" ry="25" fill={COLORS.furWhite} /> | |
| {/* ์ผ๊ตด ์์ญ */} | |
| <g transform="translate(0, -60)"> | |
| {/* ํ์ ์ฃผ๋ฅ์ด (Muzzle) */} | |
| <ellipse cy="30" rx="70" ry="50" fill="white" opacity="0.9" /> | |
| {/* ๋ (Kawaii Style) */} | |
| <g transform={`translate(${eyeOffset.x}, ${eyeOffset.y})`}> | |
| {blink ? ( | |
| <> | |
| <path d="M -80 -20 Q -60 -10 -40 -20" stroke="#333" strokeWidth="6" fill="none" strokeLinecap="round" /> | |
| <path d="M 40 -20 Q 60 -10 80 -20" stroke="#333" strokeWidth="6" fill="none" strokeLinecap="round" /> | |
| </> | |
| ) : ( | |
| <> | |
| {/* ์ผ์ชฝ ๋ */} | |
| <circle cx="-60" cy="-20" r="18" fill="#1F2937" /> | |
| <circle cx="-66" cy="-26" r="6" fill="white" /> {/* ํฐ ํ์ด๋ผ์ดํธ */} | |
| <circle cx="-54" cy="-16" r="3" fill="white" opacity="0.7" /> {/* ์์ ํ์ด๋ผ์ดํธ */} | |
| {/* ์ค๋ฅธ์ชฝ ๋ */} | |
| <circle cx="60" cy="-20" r="18" fill="#1F2937" /> | |
| <circle cx="54" cy="-26" r="6" fill="white" /> | |
| <circle cx="66" cy="-16" r="3" fill="white" opacity="0.7" /> | |
| </> | |
| )} | |
| </g> | |
| {/* ๋ณผํฐ์น */} | |
| <circle cx="-90" cy="10" r="20" fill={COLORS.cheek} opacity="0.6" filter="url(#fluffy)" /> | |
| <circle cx="90" cy="10" r="20" fill={COLORS.cheek} opacity="0.6" filter="url(#fluffy)" /> | |
| {/* ์ฝ & ์ */} | |
| <g transform={`translate(0, ${25 + noseWiggle})`}> | |
| {/* ์ฝ (์ญ์ผ๊ฐํ ๋ฅ๊ธ๊ฒ) */} | |
| <path d="M -8 -5 Q 0 5 8 -5" fill={COLORS.nose} /> | |
| <path d="M -8 -5 L 8 -5" stroke={COLORS.nose} strokeWidth="4" strokeLinecap="round"/> | |
| {/* ์ (ใ ๋ชจ์) */} | |
| <path d="M -10 5 Q 0 12 10 5" stroke={COLORS.stroke} strokeWidth="3" fill="none" strokeLinecap="round" /> | |
| <path d="M 0 5 L 0 10" stroke={COLORS.stroke} strokeWidth="2" /> | |
| </g> | |
| </g> | |
| </g> | |
| {/* ํ (Arms) - ๋ชธํต ์๋ก ๋ ๋๋ง */} | |
| <HamsterPaw side="left" landmarks={handData.left} bodyAnchor={{x: 280, y: 380}} /> | |
| <HamsterPaw side="right" landmarks={handData.right} bodyAnchor={{x: 520, y: 380}} /> | |
| </svg> | |
| ); | |
| }; | |
| const App = () => { | |
| const { videoRef, handDataRef, loading, error } = useHandTracking(); | |
| const [handDataState, setHandDataState] = useState({ left: null, right: null }); | |
| useEffect(() => { | |
| let id; | |
| const sync = () => { | |
| setHandDataState({...handDataRef.current}); | |
| id = requestAnimationFrame(sync); | |
| }; | |
| sync(); | |
| return () => cancelAnimationFrame(id); | |
| }, []); | |
| return ( | |
| <div className="relative w-full h-full flex flex-col items-center justify-center"> | |
| {/* ํ์ดํ */} | |
| <div className="absolute top-8 z-10 text-center"> | |
| <h1 className="text-4xl font-black text-amber-800 tracking-tight drop-shadow-sm"> | |
| HAMSTER MIRROR | |
| </h1> | |
| <p className="text-amber-700 font-medium mt-1">์์ ํ๋ค์ด ๋ณด์ธ์! ๐</p> | |
| </div> | |
| {/* ๋ฉ์ธ ์บ๋ฒ์ค */} | |
| <div className="w-full h-full max-w-4xl max-h-[80vh] aspect-[4/3] relative"> | |
| {loading && ( | |
| <div className="absolute inset-0 flex flex-col items-center justify-center z-20 gap-4"> | |
| <span className="loader"></span> | |
| <span className="text-amber-600 font-bold">ํ์คํฐ ๊นจ์ฐ๋ ์ค...</span> | |
| </div> | |
| )} | |
| <Hamster handData={handDataState} /> | |
| </div> | |
| {/* ๋๋ฒ๊ทธ์ฉ ์์ ํ๋ฉด */} | |
| <div className="absolute bottom-6 right-6 w-32 h-24 rounded-2xl overflow-hidden border-4 border-white shadow-xl bg-black/20 z-50"> | |
| <video ref={videoRef} className="w-full h-full object-cover transform -scale-x-100 opacity-60" autoPlay muted playsInline /> | |
| </div> | |
| {error && ( | |
| <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white/90 px-6 py-4 rounded-xl shadow-xl text-red-500 font-bold"> | |
| โ ๏ธ {error} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const root = ReactDOM.createRoot(document.getElementById('root')); | |
| root.render(<App />); | |
| </script> | |
| </body> | |
| </html> |