Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Set Solver</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| background: #000; | |
| color: #fff; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| overflow: hidden; | |
| height: 100dvh; | |
| width: 100vw; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| #trophy { | |
| display: none; | |
| flex-direction: row; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 6px; | |
| background: #111; | |
| flex-shrink: 0; | |
| } | |
| #trophy.active { display: flex; } | |
| #trophy img { | |
| height: 60px; | |
| max-width: 30vw; | |
| border-radius: 4px; | |
| border: 2px solid #4f4; | |
| object-fit: contain; | |
| } | |
| #camera-container { | |
| position: relative; | |
| flex: 1; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| } | |
| video, #result-img { | |
| max-width: 100%; | |
| max-height: 100%; | |
| object-fit: contain; | |
| } | |
| #result-img { display: none; } | |
| #bottom-bar { | |
| position: absolute; | |
| bottom: 0; left: 0; right: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding-bottom: 16px; | |
| z-index: 15; | |
| pointer-events: none; | |
| } | |
| #set-nav { | |
| display: none; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 10px; | |
| pointer-events: auto; | |
| } | |
| #set-nav.active { display: flex; } | |
| #set-nav .nav-arrow { | |
| background: rgba(255,255,255,0.2); | |
| border: none; | |
| color: #fff; | |
| font-size: 22px; | |
| width: 40px; height: 40px; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #set-nav .nav-arrow:active { background: rgba(255,255,255,0.4); } | |
| #set-label { | |
| font-size: 14px; | |
| color: #ccc; | |
| min-width: 100px; | |
| text-align: center; | |
| } | |
| #scan-btn { | |
| border: none; | |
| border-radius: 28px; | |
| padding: 14px 48px; | |
| font-size: 18px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| pointer-events: auto; | |
| } | |
| #scan-btn.start { | |
| background: #4f4; | |
| color: #000; | |
| } | |
| #scan-btn.stop { | |
| background: #f44; | |
| color: #fff; | |
| } | |
| #scan-btn.restart { | |
| background: #ff0; | |
| color: #000; | |
| } | |
| #scan-btn:active { opacity: 0.7; } | |
| #snap-btn { | |
| display: none; | |
| border: none; | |
| border-radius: 28px; | |
| padding: 14px 36px; | |
| font-size: 18px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| pointer-events: auto; | |
| background: #48f; | |
| color: #fff; | |
| margin-bottom: 8px; | |
| } | |
| #snap-btn.visible { display: block; } | |
| #snap-btn:active { opacity: 0.7; } | |
| #status-bar { | |
| position: absolute; | |
| top: 8px; left: 8px; | |
| background: rgba(0,0,0,0.6); | |
| border-radius: 8px; | |
| padding: 4px 10px; | |
| font-size: 13px; | |
| z-index: 5; | |
| } | |
| #status-bar .dot { | |
| display: inline-block; | |
| width: 8px; height: 8px; | |
| border-radius: 50%; | |
| margin-right: 6px; | |
| vertical-align: middle; | |
| } | |
| .dot.active { background: #4f4; } | |
| .dot.inactive { background: #f44; } | |
| .dot.processing { background: #ff4; } | |
| .dot.idle { background: #888; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="trophy"></div> | |
| <div id="camera-container"> | |
| <video id="video" autoplay playsinline muted></video> | |
| <img id="result-img" alt="Result"> | |
| <div id="status-bar"> | |
| <span class="dot inactive" id="status-dot"></span> | |
| <span id="status-text">Starting camera...</span> | |
| </div> | |
| <div id="bottom-bar"> | |
| <div id="set-nav"> | |
| <button class="nav-arrow" id="prev-btn">←</button> | |
| <span id="set-label"></span> | |
| <button class="nav-arrow" id="next-btn">→</button> | |
| </div> | |
| <button id="snap-btn">Snap</button> | |
| <button id="scan-btn" class="start">Start</button> | |
| </div> | |
| </div> | |
| <canvas id="capture-canvas" style="display:none;"></canvas> | |
| <script> | |
| const video = document.getElementById('video'); | |
| const resultImg = document.getElementById('result-img'); | |
| const trophy = document.getElementById('trophy'); | |
| const setNav = document.getElementById('set-nav'); | |
| const setLabel = document.getElementById('set-label'); | |
| const prevBtn = document.getElementById('prev-btn'); | |
| const nextBtn = document.getElementById('next-btn'); | |
| const scanBtn = document.getElementById('scan-btn'); | |
| const snapBtn = document.getElementById('snap-btn'); | |
| const statusDot = document.getElementById('status-dot'); | |
| const statusText = document.getElementById('status-text'); | |
| const canvas = document.getElementById('capture-canvas'); | |
| let stream = null; | |
| let scanning = false; | |
| let processing = false; | |
| let frozen = false; // true when showing results | |
| let loopTimer = null; | |
| // Result state for cycling through sets | |
| let resultData = null; | |
| let currentSetIdx = 0; | |
| async function startCamera() { | |
| if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { | |
| statusDot.className = 'dot inactive'; | |
| statusText.textContent = 'Camera API unavailable — use https://'; | |
| console.error('mediaDevices not available. Page must be served over HTTPS (or localhost).'); | |
| return; | |
| } | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }, | |
| audio: false, | |
| }); | |
| for (const track of stream.getVideoTracks()) { | |
| const caps = track.getCapabilities?.() || {}; | |
| const settings = {}; | |
| if ('backgroundBlur' in caps) settings.backgroundBlur = false; | |
| if ('faceFraming' in caps) settings.faceFraming = false; | |
| if ('pan' in caps) settings.pan = track.getSettings().pan; | |
| if ('tilt' in caps) settings.tilt = track.getSettings().tilt; | |
| if ('zoom' in caps) settings.zoom = track.getSettings().zoom; | |
| if (Object.keys(settings).length > 0) { | |
| try { await track.applyConstraints({ advanced: [settings] }); } catch (e) { /* ignore */ } | |
| } | |
| } | |
| video.srcObject = stream; | |
| await video.play(); | |
| statusDot.className = 'dot idle'; | |
| statusText.textContent = 'Ready — press Start'; | |
| } catch (err) { | |
| statusDot.className = 'dot inactive'; | |
| statusText.textContent = 'Camera access denied — check browser permissions'; | |
| console.error('Camera error:', err); | |
| } | |
| } | |
| function restart() { | |
| // Go from frozen results back to live camera (not scanning yet) | |
| frozen = false; | |
| scanning = false; | |
| resultData = null; | |
| currentSetIdx = 0; | |
| trophy.classList.remove('active'); | |
| trophy.innerHTML = ''; | |
| setNav.classList.remove('active'); | |
| resultImg.style.display = 'none'; | |
| video.style.display = 'block'; | |
| scanBtn.textContent = 'Start'; | |
| scanBtn.className = 'start'; | |
| snapBtn.classList.remove('visible'); | |
| statusDot.className = 'dot idle'; | |
| statusText.textContent = 'Ready — press Start'; | |
| } | |
| function startScanning() { | |
| scanning = true; | |
| scanBtn.textContent = 'Stop'; | |
| scanBtn.className = 'stop'; | |
| snapBtn.classList.add('visible'); | |
| statusDot.className = 'dot active'; | |
| statusText.textContent = 'Scanning...'; | |
| if (loopTimer) clearInterval(loopTimer); | |
| loopTimer = setInterval(() => { | |
| if (scanning && !processing) captureAndSolve(); | |
| }, 333); | |
| } | |
| function stopScanning() { | |
| scanning = false; | |
| if (loopTimer) { clearInterval(loopTimer); loopTimer = null; } | |
| scanBtn.textContent = 'Start'; | |
| scanBtn.className = 'start'; | |
| snapBtn.classList.remove('visible'); | |
| statusDot.className = 'dot idle'; | |
| statusText.textContent = 'Stopped'; | |
| } | |
| async function captureAndSolve() { | |
| if (!scanning || processing) return; | |
| processing = true; | |
| statusDot.className = 'dot processing'; | |
| try { | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(video, 0, 0); | |
| const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.8)); | |
| const formData = new FormData(); | |
| formData.append('file', blob, 'frame.jpg'); | |
| const resp = await fetch('/api/solve', { method: 'POST', body: formData }); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | |
| const data = await resp.json(); | |
| if (!scanning) return; | |
| statusText.textContent = `${data.num_cards} cards`; | |
| statusDot.className = 'dot active'; | |
| if (data.num_sets > 0) { | |
| showResult(data); | |
| } | |
| } catch (err) { | |
| console.error('Solve error:', err); | |
| if (scanning) statusDot.className = 'dot active'; | |
| } finally { | |
| processing = false; | |
| } | |
| } | |
| function showResult(data) { | |
| scanning = false; | |
| frozen = true; | |
| if (loopTimer) { clearInterval(loopTimer); loopTimer = null; } | |
| resultData = data; | |
| currentSetIdx = 0; | |
| video.style.display = 'none'; | |
| resultImg.style.display = 'block'; | |
| // Show nav if multiple sets | |
| if (data.num_sets > 1) { | |
| setNav.classList.add('active'); | |
| } | |
| showCurrentSet(); | |
| scanBtn.textContent = 'Restart'; | |
| scanBtn.className = 'restart'; | |
| statusDot.className = 'dot active'; | |
| statusText.textContent = `Found ${data.num_sets} Set${data.num_sets > 1 ? 's' : ''}!`; | |
| speak('Set!'); | |
| } | |
| function showCurrentSet() { | |
| if (!resultData) return; | |
| const data = resultData; | |
| const i = currentSetIdx; | |
| // Show annotated image for this set | |
| resultImg.src = 'data:image/jpeg;base64,' + data.result_images_b64[i]; | |
| // Show trophy cards for this set | |
| const cards = data.per_set_cards_b64[i]; | |
| if (cards && cards.length === 3) { | |
| trophy.innerHTML = cards | |
| .map(b64 => `<img src="data:image/jpeg;base64,${b64}">`) | |
| .join(''); | |
| trophy.classList.add('active'); | |
| } | |
| // Update nav label | |
| setLabel.textContent = `Set ${i + 1} / ${data.num_sets}`; | |
| } | |
| function prevSet() { | |
| if (!resultData || resultData.num_sets <= 1) return; | |
| currentSetIdx = (currentSetIdx - 1 + resultData.num_sets) % resultData.num_sets; | |
| showCurrentSet(); | |
| } | |
| function nextSet() { | |
| if (!resultData || resultData.num_sets <= 1) return; | |
| currentSetIdx = (currentSetIdx + 1) % resultData.num_sets; | |
| showCurrentSet(); | |
| } | |
| function speak(text) { | |
| if ('speechSynthesis' in window) { | |
| const utter = new SpeechSynthesisUtterance(text); | |
| utter.rate = 1.2; | |
| utter.pitch = 1.1; | |
| speechSynthesis.speak(utter); | |
| } | |
| } | |
| async function snapAndSolve() { | |
| // Stop continuous scanning | |
| scanning = false; | |
| if (loopTimer) { clearInterval(loopTimer); loopTimer = null; } | |
| snapBtn.classList.remove('visible'); | |
| // Capture the current frame | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(video, 0, 0); | |
| // Freeze: show the captured frame as a still image | |
| frozen = true; | |
| const snapshotDataUrl = canvas.toDataURL('image/jpeg', 0.9); | |
| resultImg.src = snapshotDataUrl; | |
| resultImg.style.display = 'block'; | |
| video.style.display = 'none'; | |
| scanBtn.textContent = 'Restart'; | |
| scanBtn.className = 'restart'; | |
| statusDot.className = 'dot processing'; | |
| statusText.textContent = 'Detecting...'; | |
| try { | |
| const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.8)); | |
| const formData = new FormData(); | |
| formData.append('file', blob, 'frame.jpg'); | |
| const resp = await fetch('/api/solve', { method: 'POST', body: formData }); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | |
| const data = await resp.json(); | |
| resultData = data; | |
| currentSetIdx = 0; | |
| if (data.num_sets > 0) { | |
| resultImg.src = 'data:image/jpeg;base64,' + data.result_images_b64[0]; | |
| if (data.num_sets > 1) setNav.classList.add('active'); | |
| showCurrentSet(); | |
| statusDot.className = 'dot active'; | |
| statusText.textContent = `Found ${data.num_sets} Set${data.num_sets > 1 ? 's' : ''}!`; | |
| speak('Set!'); | |
| } else { | |
| statusDot.className = 'dot idle'; | |
| statusText.textContent = `${data.num_cards} cards — no Sets found`; | |
| } | |
| } catch (err) { | |
| console.error('Snap solve error:', err); | |
| statusDot.className = 'dot inactive'; | |
| statusText.textContent = 'Error — try again'; | |
| } | |
| } | |
| snapBtn.addEventListener('click', snapAndSolve); | |
| scanBtn.addEventListener('click', () => { | |
| if (frozen) { | |
| restart(); | |
| } else if (scanning) { | |
| stopScanning(); | |
| } else { | |
| startScanning(); | |
| } | |
| }); | |
| prevBtn.addEventListener('click', prevSet); | |
| nextBtn.addEventListener('click', nextSet); | |
| document.addEventListener('keydown', e => { | |
| if (e.key === ' ') { | |
| e.preventDefault(); | |
| if (frozen) restart(); | |
| else if (scanning) stopScanning(); | |
| else startScanning(); | |
| } else if (e.key === 'ArrowLeft') { | |
| prevSet(); | |
| } else if (e.key === 'ArrowRight') { | |
| nextSet(); | |
| } | |
| }); | |
| startCamera(); | |
| </script> | |
| </body> | |
| </html> | |