Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Video Game Genre Predictor — Demo</title> | |
| <meta name="description" content="Live demo UI for our video game cover genre classifier." /> | |
| <style> | |
| :root { --bg:#0b1020; --card:#121933; --muted:#9aa7c7; --fg:#e9eeff; --accent:#86b7ff; --ok:#5be49b; --err:#ff6b7a; } | |
| *{box-sizing:border-box} | |
| /* Center the card on screen and remove the tall empty “floor” */ | |
| body{ min-height:100vh; } | |
| .wrap{ | |
| min-height:100vh; /* fill the screen */ | |
| display:grid; place-items:center; /* center the .card */ | |
| margin:0 auto; /* override the 40px top gap */ | |
| padding:24px 16px; | |
| } | |
| body{ | |
| margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji"; | |
| background:linear-gradient(180deg,#0b1020,#0e1430 40%); color:var(--fg); | |
| /* NEW: perspective to help the 2.5D feel */ | |
| perspective: 1400px; perspective-origin: 50% 40%; | |
| overflow-x:hidden; | |
| } | |
| .wrap{max-width:980px;margin:40px auto;padding:0 16px; position:relative; z-index:5} | |
| .card{background:var(--card); border:1px solid rgba(255,255,255,.07); border-radius:18px; padding:20px; box-shadow:0 20px 60px rgba(0,0,0,.25)} | |
| h1{font-weight:800; letter-spacing:.3px; margin:0 0 8px} | |
| p.sub{color:var(--muted); margin:0 0 16px} | |
| .grid{display:grid; gap:16px} | |
| @media(min-width:860px){ .grid{ grid-template-columns: 1.2fr .8fr } } | |
| label{display:block; font-size:14px; color:var(--muted); margin-bottom:6px} | |
| input[type="text"], button{width:100%; font-size:16px} | |
| input[type="text"]{background:#0e1530; color:var(--fg); border:1px solid rgba(255,255,255,.09); border-radius:12px; padding:10px 12px; outline:none} | |
| input[type="file"]{display:block; font-size:14px; color:var(--muted)} | |
| .btn{display:inline-flex; align-items:center; justify-content:center; gap:10px; cursor:pointer; background:var(--accent); color:#04122e; border:0; border-radius:12px; padding:12px 14px; font-weight:700} | |
| .btn[disabled]{opacity:.6; cursor:not-allowed} | |
| .row{display:flex; gap:12px; align-items:center} | |
| .thumb{width:100%; aspect-ratio: 1 / 1; background:#0a0f24; border:1px solid rgba(255,255,255,.07); border-radius:14px; display:grid; place-items:center; overflow:hidden} | |
| .thumb img{width:100%; height:100%; object-fit:cover} | |
| .meta{font-size:14px; color:var(--muted)} | |
| .pill{display:inline-block; padding:4px 10px; border-radius:999px; background:#0a1126; border:1px solid rgba(255,255,255,.08); color:var(--muted); font-size:12px; margin-right:6px} | |
| table{width:100%; border-collapse:collapse; margin-top:8px} | |
| th,td{padding:10px 8px; border-bottom:1px solid rgba(255,255,255,.08); text-align:left} | |
| th{color:#b7c5ec; font-weight:600} | |
| .ok{color:var(--ok)} | |
| .err{color:var(--err)} | |
| .hidden{display:none} | |
| .code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size:13px; color:#cfe7ff; background:#0a0f24; padding:10px 12px; border-radius:10px; border:1px solid rgba(255,255,255,.07)} | |
| footer{margin-top:22px; color:var(--muted); font-size:13px} | |
| /* ========= NEW: Moving 2.5D background of game covers ========= */ | |
| .bg-covers{ | |
| position:fixed; inset:0; | |
| overflow:hidden; z-index:0; pointer-events:none; | |
| /* a subtle vignette so the center “focus point” pops */ | |
| } | |
| .bg-vignette{ | |
| position:absolute; inset:-10%; | |
| background: radial-gradient(ellipse at center, | |
| rgba(0,0,0,0) 0%, | |
| rgba(0,0,0,0.25) 55%, | |
| rgba(0,0,0,0.6) 100%); | |
| z-index:2; pointer-events:none; | |
| } | |
| .covers-layer{ | |
| position:absolute; left:50%; top:50%; | |
| transform-style:preserve-3d; | |
| z-index:1; | |
| } | |
| .cover-tile{ | |
| position:absolute; | |
| width:220px; height:300px; /* card-ish aspect for covers */ | |
| border-radius:14px; overflow:hidden; | |
| box-shadow: 0 30px 80px rgba(0,0,0,.55), 0 0 1px rgba(255,255,255,.08) inset; | |
| transform: translate3d(0,0,0) rotateX(var(--rx)) rotateY(var(--ry)) rotateZ(var(--rz)) scale(var(--sc)); | |
| opacity:0; | |
| animation: stream var(--dur) linear var(--delay) infinite; | |
| will-change: transform, opacity; | |
| filter: saturate(1.1) contrast(1.05); | |
| mix-blend-mode: screen; /* helps them glow over the dark bg */ | |
| } | |
| .cover-tile img{ | |
| width:100%; height:100%; object-fit:cover; display:block; | |
| transform: translateZ(0); | |
| } | |
| @keyframes stream{ | |
| 0%{ | |
| transform: | |
| translate3d(0px,0px,0) | |
| rotateX(var(--rx)) rotateY(var(--ry)) rotateZ(var(--rz)) | |
| scale(calc(var(--sc) * .45)); | |
| opacity:0; | |
| } | |
| 15%{ opacity:.85; } | |
| 70%{ | |
| opacity:.95; | |
| } | |
| 100%{ | |
| transform: | |
| translate3d(var(--tx), var(--ty), var(--tz)) | |
| rotateX(var(--rx)) rotateY(var(--ry)) rotateZ(var(--rz)) | |
| scale(var(--sc)); | |
| opacity:.0; /* fade as it reaches the edge */ | |
| } | |
| } | |
| /* NEW: a soft center “focus point” */ | |
| .focus-point{ | |
| position:fixed; left:50%; top:50%; translate:-50% -50%; | |
| width:180px; height:180px; border-radius:50%; | |
| background: radial-gradient(circle at center, rgba(255,255,255,.12), rgba(255,255,255,0) 60%); | |
| box-shadow: 0 0 160px 40px rgba(134,183,255,.15); | |
| z-index:3; pointer-events:none; | |
| filter: blur(1px); | |
| } | |
| /* NEW: lifts the card above the covers comfortably */ | |
| .card{ position:relative; z-index:4; } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ========= NEW: moving background ========== --> | |
| <div class="bg-covers" id="bg"> | |
| <div class="covers-layer" id="coversLayer" aria-hidden="true"></div> | |
| <div class="bg-vignette" aria-hidden="true"></div> | |
| </div> | |
| <div class="focus-point" aria-hidden="true"></div> | |
| <!-- ========================================== --> | |
| <div class="wrap"> | |
| <div class="card"> | |
| <h1>Video Game Genre Predictor</h1> | |
| <p class="sub">Upload a game cover or paste an image URL. The model returns the predicted genre and top-k probabilities.</p> | |
| <div class="grid"> | |
| <!-- LEFT: Controls --> | |
| <section> | |
| <div style="margin-bottom:16px"> | |
| <label for="imageUrl">Image URL (optional)</label> | |
| <input id="imageUrl" type="text" placeholder="https://.../cover.jpg" /> | |
| </div> | |
| <div style="margin-bottom:14px"> | |
| <label for="file">Or upload an image</label> | |
| <input id="file" type="file" accept="image/*" /> | |
| </div> | |
| <div class="row" style="margin-bottom:16px"> | |
| <button id="predictBtn" class="btn">▶ Run Prediction</button> | |
| <button id="clearBtn" class="btn" style="background:#1c264a; color:#cfe1ff">⟲ Clear</button> | |
| </div> | |
| <div id="status" class="meta">Ready.</div> | |
| <details style="margin-top:12px"> | |
| <summary class="meta">Client → API details</summary> | |
| <div class="code" id="dbg"></div> | |
| </details> | |
| </section> | |
| <!-- RIGHT: Preview & Results --> | |
| <section> | |
| <div class="thumb" id="preview"><span class="meta">No image selected</span></div> | |
| <div style="margin-top:12px" id="result" class="hidden"> | |
| <div><span class="pill" id="modelName">model: —</span><span class="pill" id="latency">latency: — ms</span></div> | |
| <h3 style="margin:12px 0 6px">Top-k predictions</h3> | |
| <table> | |
| <thead><tr><th>Rank</th><th>Genre</th><th>Probability</th></tr></thead> | |
| <tbody id="topk"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </div> | |
| <footer> | |
| <div>API endpoint: <span class="code" id="apiBase">(set in code)</span></div> | |
| </footer> | |
| </div> | |
| </div> | |
| <script> | |
| // ===== 1) SET YOUR BACKEND URL HERE ===== | |
| // Example: "https://yourname.rose-hulman.edu/genre" | |
| const API_BASE = ""; | |
| // Optional: if your backend uses a different path for URL vs file, set here: | |
| const ENDPOINT_PREDICT = "/predict"; // POST multipart (file) OR JSON {url} | |
| // ===== UI handles ===== | |
| const el = (id) => document.getElementById(id); | |
| const imageUrl = el('imageUrl'); | |
| const file = el('file'); | |
| const predictBtn = el('predictBtn'); | |
| const clearBtn = el('clearBtn'); | |
| const preview = el('preview'); | |
| const result = el('result'); | |
| const topk = el('topk'); | |
| const status = el('status'); | |
| const dbg = el('dbg'); | |
| const apiBase = el('apiBase'); | |
| const modelName = el('modelName'); | |
| const latency = el('latency'); | |
| apiBase.textContent = API_BASE + ENDPOINT_PREDICT; | |
| function setStatus(msg, kind="info"){ | |
| status.textContent = msg; | |
| status.className = "meta" + (kind === 'ok' ? ' ok' : kind === 'err' ? ' err' : ''); | |
| } | |
| function showPreviewFromUrl(url){ | |
| preview.innerHTML = `<img src="${url}" alt="preview"/>`; | |
| } | |
| file.addEventListener('change', () => { | |
| const f = file.files?.[0]; | |
| if(!f){ preview.innerHTML = '<span class="meta">No image selected</span>'; return; } | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { preview.innerHTML = `<img src="${e.target.result}" alt="preview"/>`; }; | |
| reader.readAsDataURL(f); | |
| }); | |
| clearBtn.addEventListener('click', () => { | |
| imageUrl.value = ''; | |
| file.value = ''; | |
| preview.innerHTML = '<span class="meta">No image selected</span>'; | |
| result.classList.add('hidden'); | |
| topk.innerHTML = ''; | |
| setStatus('Cleared. Ready.'); | |
| dbg.textContent = ''; | |
| }); | |
| async function callPredict() { | |
| const url = imageUrl.value.trim(); | |
| const f = file.files?.[0] || null; | |
| if(!url && !f){ setStatus('Provide an image URL or upload a file.', 'err'); return; } | |
| setStatus('Sending to model…'); | |
| predictBtn.disabled = true; | |
| const t0 = performance.now(); | |
| let resp, payloadDesc; | |
| try { | |
| if(f){ | |
| const form = new FormData(); | |
| form.append('file', f); | |
| resp = await fetch(API_BASE + ENDPOINT_PREDICT, { method:'POST', body: form }); | |
| payloadDesc = 'multipart/form-data (file)'; | |
| } else { | |
| resp = await fetch(API_BASE + ENDPOINT_PREDICT, { | |
| method:'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ url }) | |
| }); | |
| payloadDesc = 'application/json {url}'; | |
| showPreviewFromUrl(url); | |
| } | |
| } catch (e) { | |
| setStatus('Network error. Open console for details.', 'err'); | |
| console.error(e); | |
| predictBtn.disabled = false; | |
| return; | |
| } | |
| const t1 = performance.now(); | |
| let data; | |
| try { data = await resp.json(); } catch { data = null; } | |
| dbg.textContent = `POST ${API_BASE+ENDPOINT_PREDICT}\nPayload: ${payloadDesc}\nStatus: ${resp.status}`; | |
| if(!resp.ok || !data){ | |
| setStatus(`API error (${resp.status}).`, 'err'); | |
| result.classList.add('hidden'); | |
| predictBtn.disabled = false; | |
| return; | |
| } | |
| // Expected JSON shape (example): | |
| // { label: "action", top_k:[{"label":"action","prob":0.72},...], model:"resnet50-ft", latency_ms:123 } | |
| const tk = (data.top_k || []).slice(0, 5); | |
| topk.innerHTML = tk.map((r, i) => ` | |
| <tr> | |
| <td>#${i+1}</td> | |
| <td>${r.label ?? '-'}</td> | |
| <td>${typeof r.prob === 'number' ? (r.prob*100).toFixed(2)+'%' : '-'}</td> | |
| </tr>`).join(''); | |
| modelName.textContent = `model: ${data.model ?? '—'}`; | |
| latency.textContent = `latency: ${Math.round(data.latency_ms ?? (t1 - t0))} ms`; | |
| result.classList.remove('hidden'); | |
| setStatus(`Done. Prediction: ${data.label ?? tk[0]?.label ?? '—'}`, 'ok'); | |
| predictBtn.disabled = false; | |
| } | |
| predictBtn.addEventListener('click', callPredict); | |
| /* ========= NEW: Moving covers background logic ========= */ | |
| const coversLayer = document.getElementById('coversLayer'); | |
| // Example cover images — replace with your own list (Steam grid images, CDN, etc.) | |
| const COVER_URLS = [ | |
| "covers/bro.jpg", | |
| "covers/cook.jpg", | |
| "covers/detroit.jpg", | |
| "covers/Doki.Doki-1200.jpg", | |
| "covers/HollowKnight.jpg", | |
| "covers/hotel.jpg", | |
| "covers/issac.jpg", | |
| "covers/moncage.jpg", | |
| "covers/raft.jpg", | |
| "covers/Silksong.jpg", | |
| "covers/Stardew.jpg", | |
| "covers/traveler.jpg", | |
| "covers/wukong.jpg", | |
| "covers/1.jpg", | |
| "covers/2.jpg", | |
| "covers/3.jpg", | |
| "covers/4.jpg", | |
| "covers/5.jpg", | |
| "covers/6.jpg", | |
| "covers/7.jpg", | |
| ]; | |
| const NUM_TILES = 36; // how many covers on screen | |
| const EDGE_RADIUS = Math.max(window.innerWidth, window.innerHeight) * 0.9; // how far they fly | |
| const MIN_DUR = 12, MAX_DUR = 26; // seconds per fly-out | |
| function rand(min, max){ return Math.random()*(max-min)+min; } | |
| // Create tiles that animate from center to the edges along random rays. | |
| function spawnTiles(){ | |
| for(let i=0;i<NUM_TILES;i++){ | |
| const url = COVER_URLS[i % COVER_URLS.length]; | |
| const tile = document.createElement('div'); | |
| tile.className = 'cover-tile'; | |
| // Pick a random direction (angle) from the center, with slight bias to spread nicely. | |
| const angle = rand(0, Math.PI*2); | |
| const r = EDGE_RADIUS * rand(0.85,1.1); // how far to travel | |
| const tx = Math.cos(angle) * r; | |
| const ty = Math.sin(angle) * r; | |
| // 2.5D tilt: lay them down a bit, randomize subtly | |
| const rx = `${rand(-14,-6)}deg`; // tilt down a bit | |
| const ry = `${rand(-6,6)}deg`; | |
| const rz = `${rand(-8,8)}deg`; | |
| const sc = rand(0.9,1.15); | |
| // Depth for extra parallax | |
| const tz = `${rand(-120,120)}px`; | |
| const dur = `${rand(MIN_DUR, MAX_DUR)}s`; | |
| const delay = `${-rand(0, MAX_DUR)}s`; // negative so they start mid-animation for a “full” field | |
| tile.style.setProperty('--tx', `${tx}px`); | |
| tile.style.setProperty('--ty', `${ty}px`); | |
| tile.style.setProperty('--tz', tz); | |
| tile.style.setProperty('--rx', rx); | |
| tile.style.setProperty('--ry', ry); | |
| tile.style.setProperty('--rz', rz); | |
| tile.style.setProperty('--sc', sc); | |
| tile.style.setProperty('--dur', dur); | |
| tile.style.setProperty('--delay', delay); | |
| // random offset around the center so they don't overlap at the exact same origin | |
| const jitterX = rand(-40, 40), jitterY = rand(-40, 40); | |
| tile.style.left = jitterX + 'px'; | |
| tile.style.top = jitterY + 'px'; | |
| const img = document.createElement('img'); | |
| img.src = url; | |
| img.alt = "background game cover"; | |
| img.loading = "lazy"; | |
| tile.appendChild(img); | |
| coversLayer.appendChild(tile); | |
| } | |
| } | |
| // Parallax: rotate the whole layer slightly based on mouse | |
| let targetRX = 0, targetRY = 0, curRX = 0, curRY = 0; | |
| function onMouseMove(e){ | |
| const cx = window.innerWidth / 2; | |
| const cy = window.innerHeight / 2; | |
| const dx = (e.clientX - cx) / cx; // -1..1 | |
| const dy = (e.clientY - cy) / cy; // -1..1 | |
| targetRY = dx * 8; // yaw | |
| targetRX = -dy * 6; // pitch | |
| } | |
| window.addEventListener('mousemove', onMouseMove); | |
| function animateParallax(){ | |
| curRX += (targetRX - curRX) * 0.08; | |
| curRY += (targetRY - curRY) * 0.08; | |
| coversLayer.style.transform = `rotateX(${curRX}deg) rotateY(${curRY}deg) translateZ(0)`; | |
| requestAnimationFrame(animateParallax); | |
| } | |
| // Handle resize (recompute travel radius a bit so the stream still clears the edges) | |
| window.addEventListener('resize', () => { | |
| // Optionally, you could rebuild tiles for a perfect fit; keeping it simple for performance. | |
| }); | |
| spawnTiles(); | |
| animateParallax(); | |
| /* ======== end moving covers background ======== */ | |
| </script> | |
| </body> | |
| </html> | |