Legend0908's picture
Upload 6 files
26f05ca verified
<!DOCTYPE html>
<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>