| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>AR美甲工作室</title> |
| <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> |
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; |
| } |
| body { |
| background-color: #f8f8f8; |
| overflow: hidden; |
| position: fixed; |
| width: 100%; |
| height: 100%; |
| touch-action: none; |
| } |
| .container { |
| position: relative; |
| width: 100%; |
| height: 100%; |
| overflow: hidden; |
| } |
| .input_video { |
| position: absolute; |
| width: 100%; |
| height: 100%; |
| left: 0; |
| top: 0; |
| object-fit: cover; |
| opacity: 0; |
| pointer-events: none; |
| } |
| .output_canvas { |
| position: absolute; |
| width: 100%; |
| height: 100%; |
| left: 0; |
| top: 0; |
| object-fit: cover; |
| } |
| .controls-container { |
| position: absolute; |
| bottom: 0; |
| left: 0; |
| width: 100%; |
| padding: 20px; |
| background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent); |
| z-index: 10; |
| } |
| .controls-content { |
| max-width: 500px; |
| margin: 0 auto; |
| } |
| .control-section { |
| margin-bottom: 25px; |
| } |
| .section-title { |
| color: white; |
| font-size: 14px; |
| font-weight: 600; |
| margin-bottom: 12px; |
| letter-spacing: 0.5px; |
| text-transform: uppercase; |
| } |
| .design-options { |
| display: flex; |
| overflow-x: auto; |
| padding-bottom: 8px; |
| gap: 12px; |
| scrollbar-width: none; |
| } |
| .design-options::-webkit-scrollbar { |
| display: none; |
| } |
| .design-option { |
| width: 60px; |
| height: 60px; |
| border-radius: 12px; |
| overflow: hidden; |
| position: relative; |
| border: 2px solid transparent; |
| transition: all 0.2s ease; |
| flex-shrink: 0; |
| } |
| .design-option.selected { |
| border-color: #fff; |
| transform: scale(1.05); |
| box-shadow: 0 0 15px rgba(255, 255, 255, 0.5); |
| } |
| .slider-container { |
| margin: 15px 0; |
| color: white; |
| } |
| .slider-label { |
| display: flex; |
| justify-content: space-between; |
| font-size: 12px; |
| margin-bottom: 8px; |
| } |
| .slider { |
| -webkit-appearance: none; |
| appearance: none; |
| width: 100%; |
| height: 4px; |
| background: rgba(255, 255, 255, 0.3); |
| border-radius: 2px; |
| outline: none; |
| } |
| .slider::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| appearance: none; |
| width: 18px; |
| height: 18px; |
| border-radius: 50%; |
| background: white; |
| cursor: pointer; |
| box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); |
| } |
| .toggle-section { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| .toggle-container { |
| display: flex; |
| align-items: center; |
| } |
| .toggle-label { |
| color: white; |
| font-size: 14px; |
| margin-right: 10px; |
| } |
| .toggle { |
| position: relative; |
| width: 46px; |
| height: 24px; |
| border-radius: 12px; |
| background-color: rgba(255, 255, 255, 0.3); |
| transition: all 0.3s ease; |
| } |
| .toggle.active { |
| background-color: #3a86ff; |
| } |
| .toggle-handle { |
| position: absolute; |
| top: 2px; |
| left: 2px; |
| width: 20px; |
| height: 20px; |
| border-radius: 50%; |
| background-color: white; |
| transition: all 0.3s ease; |
| box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); |
| } |
| .toggle.active .toggle-handle { |
| left: 24px; |
| } |
| .snap-button { |
| position: absolute; |
| bottom: 240px; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 64px; |
| height: 64px; |
| border-radius: 50%; |
| background-color: white; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); |
| z-index: 20; |
| border: none; |
| outline: none; |
| } |
| .snap-button:active { |
| transform: translateX(-50%) scale(0.95); |
| } |
| .snap-button::after { |
| content: ''; |
| width: 52px; |
| height: 52px; |
| border-radius: 50%; |
| border: 2px solid #f8f8f8; |
| } |
| .camera-flip { |
| position: absolute; |
| top: 20px; |
| right: 20px; |
| background: rgba(0, 0, 0, 0.5); |
| color: white; |
| border: none; |
| border-radius: 50%; |
| width: 40px; |
| height: 40px; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| z-index: 20; |
| font-size: 18px; |
| } |
| .loading-screen { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background-color: #000; |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| z-index: 9999; |
| transition: opacity 0.5s ease; |
| } |
| .loading-spinner { |
| width: 50px; |
| height: 50px; |
| border: 3px solid rgba(255, 255, 255, 0.2); |
| border-radius: 50%; |
| border-top-color: white; |
| animation: spin 1s linear infinite; |
| margin-bottom: 20px; |
| } |
| .loading-text { |
| color: white; |
| font-size: 18px; |
| font-weight: 500; |
| } |
| @keyframes spin { |
| to { |
| transform: rotate(360deg); |
| } |
| } |
| .app-title { |
| position: absolute; |
| top: 20px; |
| left: 20px; |
| color: white; |
| font-size: 18px; |
| font-weight: 600; |
| text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); |
| z-index: 20; |
| } |
| |
| .color-block { |
| width: 100%; |
| height: 100%; |
| } |
| |
| .camera-permission { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background-color: rgba(0, 0, 0, 0.8); |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| z-index: 9998; |
| color: white; |
| text-align: center; |
| padding: 20px; |
| } |
| |
| .camera-permission h2 { |
| margin-bottom: 20px; |
| font-size: 24px; |
| } |
| |
| .camera-permission p { |
| margin-bottom: 30px; |
| font-size: 16px; |
| max-width: 500px; |
| } |
| |
| .allow-camera-btn { |
| background-color: #3a86ff; |
| color: white; |
| border: none; |
| padding: 12px 24px; |
| border-radius: 24px; |
| font-size: 16px; |
| font-weight: 500; |
| cursor: pointer; |
| } |
| |
| .debug-info { |
| position: fixed; |
| top: 70px; |
| left: 20px; |
| color: white; |
| background-color: rgba(0,0,0,0.7); |
| padding: 10px; |
| border-radius: 5px; |
| font-size: 12px; |
| z-index: 100; |
| max-width: 300px; |
| display: none; |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <div class="loading-screen"> |
| <div class="loading-spinner"></div> |
| <div class="loading-text">正在加载AR美甲工作室...</div> |
| </div> |
| |
| <div class="camera-permission" id="cameraPermission"> |
| <h2>需要摄像头权限</h2> |
| <p>AR美甲工作室需要使用您的摄像头来实时显示美甲效果。请点击下方按钮允许使用摄像头。</p> |
| <button class="allow-camera-btn" id="allowCameraBtn">允许使用摄像头</button> |
| </div> |
|
|
| <div class="debug-info" id="debugInfo"></div> |
|
|
| <div class="container"> |
| <video class="input_video" playsinline></video> |
| <canvas class="output_canvas"></canvas> |
| |
| <div class="app-title">AR美甲工作室</div> |
| |
| <button class="camera-flip"> |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| <path d="M9 16V10H15"></path> |
| <path d="M15 16L9 10"></path> |
| <path d="M12 3C16.971 3 21 7.029 21 12C21 16.971 16.971 21 12 21C7.029 21 3 16.971 3 12"></path> |
| <path d="M3 4V8H7"></path> |
| </svg> |
| </button> |
| |
| <button class="snap-button"></button> |
| |
| <div class="controls-container"> |
| <div class="controls-content"> |
| <div class="control-section"> |
| <div class="section-title">美甲设计</div> |
| <div class="design-options"> |
| <div class="design-option selected" data-design="pink"> |
| <div class="color-block" style="background: linear-gradient(45deg, #ff9a9e, #fad0c4);"></div> |
| </div> |
| <div class="design-option" data-design="blue"> |
| <div class="color-block" style="background: linear-gradient(45deg, #2193b0, #6dd5ed);"></div> |
| </div> |
| <div class="design-option" data-design="purple"> |
| <div class="color-block" style="background: linear-gradient(45deg, #c471f5, #fa71cd);"></div> |
| </div> |
| <div class="design-option" data-design="gold"> |
| <div class="color-block" style="background: linear-gradient(45deg, #f6d365, #fda085);"></div> |
| </div> |
| <div class="design-option" data-design="green"> |
| <div class="color-block" style="background: linear-gradient(45deg, #43c6ac, #f8ffae);"></div> |
| </div> |
| <div class="design-option" data-design="black"> |
| <div class="color-block" style="background: linear-gradient(45deg, #232526, #414345);"></div> |
| </div> |
| </div> |
| </div> |
| |
| <div class="control-section"> |
| <div class="section-title">调整</div> |
| |
| <div class="slider-container"> |
| <div class="slider-label"> |
| <span>大小</span> |
| <span id="size-value">100%</span> |
| </div> |
| <input type="range" min="50" max="150" value="100" class="slider" id="size-slider"> |
| </div> |
| |
| <div class="slider-container"> |
| <div class="slider-label"> |
| <span>长度</span> |
| <span id="length-value">100%</span> |
| </div> |
| <input type="range" min="80" max="200" value="100" class="slider" id="length-slider"> |
| </div> |
| |
| <div class="slider-container"> |
| <div class="slider-label"> |
| <span>透明度</span> |
| <span id="opacity-value">100%</span> |
| </div> |
| <input type="range" min="30" max="100" value="100" class="slider" id="opacity-slider"> |
| </div> |
| </div> |
| |
| <div class="control-section toggle-section"> |
| <div class="toggle-container"> |
| <span class="toggle-label">真实阴影</span> |
| <div class="toggle active" id="shadow-toggle"> |
| <div class="toggle-handle"></div> |
| </div> |
| </div> |
| |
| <div class="toggle-container"> |
| <span class="toggle-label">显示手部轮廓</span> |
| <div class="toggle" id="lines-toggle"> |
| <div class="toggle-handle"></div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| |
| const nailDesigns = { |
| pink: { |
| color: 'linear-gradient(45deg, #ff9a9e, #fad0c4)', |
| shadowColor: 'rgba(255, 154, 158, 0.5)' |
| }, |
| blue: { |
| color: 'linear-gradient(45deg, #2193b0, #6dd5ed)', |
| shadowColor: 'rgba(33, 147, 176, 0.5)' |
| }, |
| purple: { |
| color: 'linear-gradient(45deg, #c471f5, #fa71cd)', |
| shadowColor: 'rgba(196, 113, 245, 0.5)' |
| }, |
| gold: { |
| color: 'linear-gradient(45deg, #f6d365, #fda085)', |
| shadowColor: 'rgba(246, 211, 101, 0.5)' |
| }, |
| green: { |
| color: 'linear-gradient(45deg, #43c6ac, #f8ffae)', |
| shadowColor: 'rgba(67, 198, 172, 0.5)' |
| }, |
| black: { |
| color: 'linear-gradient(45deg, #232526, #414345)', |
| shadowColor: 'rgba(35, 37, 38, 0.5)' |
| } |
| }; |
| |
| |
| let currentDesign = 'pink'; |
| let sizeScale = 1.0; |
| let lengthScale = 1.0; |
| let opacity = 1.0; |
| let showShadows = true; |
| let showLines = false; |
| let facingMode = 'environment'; |
| let camera; |
| let cameraInitialized = false; |
| |
| |
| let hands; |
| |
| |
| const debugInfo = document.getElementById('debugInfo'); |
| function showDebug(text) { |
| debugInfo.style.display = 'block'; |
| debugInfo.textContent = text; |
| } |
| |
| |
| const videoElement = document.getElementsByClassName('input_video')[0]; |
| const canvasElement = document.getElementsByClassName('output_canvas')[0]; |
| const cameraPermissionElement = document.getElementById('cameraPermission'); |
| const allowCameraButton = document.getElementById('allowCameraBtn'); |
| |
| |
| canvasElement.width = window.innerWidth; |
| canvasElement.height = window.innerHeight; |
| const canvasCtx = canvasElement.getContext('2d'); |
| |
| |
| const sizeSlider = document.getElementById('size-slider'); |
| const sizeValue = document.getElementById('size-value'); |
| const lengthSlider = document.getElementById('length-slider'); |
| const lengthValue = document.getElementById('length-value'); |
| const opacitySlider = document.getElementById('opacity-slider'); |
| const opacityValue = document.getElementById('opacity-value'); |
| const shadowToggle = document.getElementById('shadow-toggle'); |
| const linesToggle = document.getElementById('lines-toggle'); |
| const flipButton = document.querySelector('.camera-flip'); |
| const snapButton = document.querySelector('.snap-button'); |
| const designOptions = document.querySelectorAll('.design-option'); |
| |
| |
| document.addEventListener('DOMContentLoaded', function() { |
| document.querySelector('.loading-screen').style.opacity = 0; |
| setTimeout(() => { |
| document.querySelector('.loading-screen').style.display = 'none'; |
| }, 500); |
| }); |
| |
| |
| allowCameraButton.addEventListener('click', () => { |
| initializeARApp(); |
| }); |
| |
| |
| function applyNailDesign(ctx, fingerTip, fingerBase, design) { |
| |
| const tipX = fingerTip.x * canvasElement.width; |
| const tipY = fingerTip.y * canvasElement.height; |
| const baseX = fingerBase.x * canvasElement.width; |
| const baseY = fingerBase.y * canvasElement.height; |
| |
| |
| const fingerAngle = Math.atan2(tipY - baseY, tipX - baseX); |
| const fingerLength = Math.sqrt(Math.pow(tipX - baseX, 2) + Math.pow(tipY - baseY, 2)); |
| |
| |
| const nailWidth = fingerLength * 0.6 * sizeScale; |
| const nailLength = fingerLength * 0.7 * lengthScale; |
| |
| |
| ctx.save(); |
| |
| |
| ctx.translate(tipX, tipY); |
| ctx.rotate(fingerAngle); |
| |
| |
| if (showShadows) { |
| ctx.save(); |
| ctx.shadowColor = nailDesigns[design].shadowColor; |
| ctx.shadowBlur = 15; |
| ctx.shadowOffsetX = 0; |
| ctx.shadowOffsetY = 5; |
| ctx.fillStyle = 'rgba(0,0,0,0)'; |
| ctx.beginPath(); |
| ctx.ellipse(0, 0, nailWidth/2, nailLength/2, 0, 0, Math.PI * 2); |
| ctx.fill(); |
| ctx.restore(); |
| } |
| |
| |
| ctx.beginPath(); |
| ctx.ellipse(0, 0, nailWidth/2, nailLength/2, 0, 0, Math.PI * 2); |
| ctx.clip(); |
| |
| |
| const gradient = ctx.createLinearGradient(-nailWidth/2, -nailLength/2, nailWidth/2, nailLength/2); |
| |
| try { |
| |
| const gradientStr = nailDesigns[design].color; |
| |
| const colorMatches = gradientStr.match(/#[0-9a-f]{6}/gi); |
| |
| if (colorMatches && colorMatches.length >= 2) { |
| const colorStart = colorMatches[0]; |
| const colorEnd = colorMatches[1]; |
| |
| gradient.addColorStop(0, colorStart); |
| gradient.addColorStop(1, colorEnd); |
| } else { |
| |
| gradient.addColorStop(0, '#ff9a9e'); |
| gradient.addColorStop(1, '#fad0c4'); |
| console.warn('无法解析颜色,使用默认值'); |
| } |
| } catch (error) { |
| |
| console.error('创建渐变时出错:', error); |
| |
| gradient.addColorStop(0, '#ff9a9e'); |
| gradient.addColorStop(1, '#fad0c4'); |
| } |
| |
| |
| ctx.globalAlpha = opacity; |
| ctx.fillStyle = gradient; |
| ctx.fillRect(-nailWidth/2, -nailLength/2, nailWidth, nailLength); |
| |
| |
| ctx.restore(); |
| } |
| |
| |
| function onResults(results) { |
| |
| canvasCtx.save(); |
| canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height); |
| canvasCtx.drawImage( |
| results.image, 0, 0, canvasElement.width, canvasElement.height |
| ); |
| |
| |
| if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { |
| for (const landmarks of results.multiHandLandmarks) { |
| |
| if (showLines) { |
| drawConnectors(canvasCtx, landmarks, HAND_CONNECTIONS, |
| {color: 'rgba(255, 255, 255, 0.5)', lineWidth: 2}); |
| } |
| |
| |
| |
| applyNailDesign(canvasCtx, landmarks[4], landmarks[3], currentDesign); |
| |
| applyNailDesign(canvasCtx, landmarks[8], landmarks[7], currentDesign); |
| |
| applyNailDesign(canvasCtx, landmarks[12], landmarks[11], currentDesign); |
| |
| applyNailDesign(canvasCtx, landmarks[16], landmarks[15], currentDesign); |
| |
| applyNailDesign(canvasCtx, landmarks[20], landmarks[19], currentDesign); |
| } |
| } |
| |
| canvasCtx.restore(); |
| } |
| |
| |
| function initializeARApp() { |
| try { |
| cameraPermissionElement.style.display = 'none'; |
| |
| if (!cameraInitialized) { |
| showDebug("正在初始化手部跟踪..."); |
| |
| |
| 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); |
| |
| |
| setupCamera(); |
| cameraInitialized = true; |
| |
| showDebug("手部跟踪已初始化"); |
| } |
| } catch (error) { |
| showDebug("初始化失败: " + error.message); |
| console.error("初始化AR应用失败:", error); |
| } |
| } |
| |
| |
| sizeSlider.addEventListener('input', (e) => { |
| sizeScale = parseInt(e.target.value) / 100; |
| sizeValue.textContent = `${e.target.value}%`; |
| }); |
| |
| lengthSlider.addEventListener('input', (e) => { |
| lengthScale = parseInt(e.target.value) / 100; |
| lengthValue.textContent = `${e.target.value}%`; |
| }); |
| |
| opacitySlider.addEventListener('input', (e) => { |
| opacity = parseInt(e.target.value) / 100; |
| opacityValue.textContent = `${e.target.value}%`; |
| }); |
| |
| shadowToggle.addEventListener('click', () => { |
| shadowToggle.classList.toggle('active'); |
| showShadows = shadowToggle.classList.contains('active'); |
| }); |
| |
| linesToggle.addEventListener('click', () => { |
| linesToggle.classList.toggle('active'); |
| showLines = linesToggle.classList.contains('active'); |
| }); |
| |
| flipButton.addEventListener('click', () => { |
| facingMode = facingMode === 'user' ? 'environment' : 'user'; |
| |
| if (camera) { |
| camera.stop(); |
| } |
| setupCamera(); |
| }); |
| |
| designOptions.forEach(option => { |
| option.addEventListener('click', () => { |
| designOptions.forEach(opt => opt.classList.remove('selected')); |
| option.classList.add('selected'); |
| currentDesign = option.getAttribute('data-design'); |
| }); |
| }); |
| |
| snapButton.addEventListener('click', () => { |
| const dataUrl = canvasElement.toDataURL('image/png'); |
| |
| |
| const link = document.createElement('a'); |
| link.href = dataUrl; |
| link.download = 'AR-美甲设计.png'; |
| document.body.appendChild(link); |
| link.click(); |
| document.body.removeChild(link); |
| |
| |
| snapButton.style.backgroundColor = '#3a86ff'; |
| setTimeout(() => { |
| snapButton.style.backgroundColor = 'white'; |
| }, 300); |
| }); |
| |
| |
| function setupCamera() { |
| try { |
| showDebug("正在初始化摄像头..."); |
| |
| camera = new Camera(videoElement, { |
| onFrame: async () => { |
| if (hands) { |
| try { |
| await hands.send({image: videoElement}); |
| } catch (error) { |
| showDebug("手部跟踪出错: " + error.message); |
| } |
| } |
| }, |
| width: 1280, |
| height: 720, |
| facingMode: facingMode |
| }); |
| |
| camera.start() |
| .then(() => { |
| showDebug("摄像头已启动"); |
| setTimeout(() => { |
| debugInfo.style.display = 'none'; |
| }, 3000); |
| }) |
| .catch(error => { |
| console.error('摄像头启动失败: ', error); |
| showDebug("摄像头启动失败: " + error.message); |
| |
| cameraPermissionElement.style.display = 'flex'; |
| }); |
| } catch (error) { |
| showDebug("摄像头初始化失败: " + error.message); |
| console.error("摄像头初始化失败:", error); |
| } |
| } |
| |
| |
| window.addEventListener('resize', () => { |
| canvasElement.width = window.innerWidth; |
| canvasElement.height = window.innerHeight; |
| }); |
| </script> |
| </body> |
| </html> |