hamster / index.html
kimhyunwoo's picture
Update index.html
ec28113 verified
<!DOCTYPE html>
<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>