Tian Wang
Add snapshot mode with Snap button
f746e93
<!DOCTYPE html>
<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">&larr;</button>
<span id="set-label"></span>
<button class="nav-arrow" id="next-btn">&rarr;</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>