Spaces:
Running on Zero
Running on Zero
multimodalart HF Staff
Revert look/pan to keyboard IJKL (replacing mouse-look), keep WASD movement
304b499 verified | <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> | |
| <title>ABot-World · Interactive World Rollout</title> | |
| <style> | |
| :root{ | |
| --bg:#0b0e14; --panel:#121722; --panel-2:#171d2b; --edge:#232c3d; | |
| --text:#e6ecf5; --muted:#8a97ad; --accent:#5b8cff; --accent-2:#7c5cff; | |
| --good:#3ecf8e; --warn:#ffb454; --bad:#ff5c6c; --radius:16px; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{margin:0;height:100%} | |
| body{ | |
| font-family:'Inter',system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif; | |
| background:radial-gradient(1200px 700px at 80% -10%, #1a2138 0%, var(--bg) 60%) fixed; | |
| color:var(--text); -webkit-font-smoothing:antialiased; min-height:100%; | |
| } | |
| .wrap{max-width:1240px; margin:0 auto; padding:28px 20px 48px} | |
| header{display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; margin-bottom:22px} | |
| .brand{display:flex; align-items:center; gap:12px} | |
| .logo{width:42px;height:42px;border-radius:12px; | |
| background:linear-gradient(135deg,var(--accent),var(--accent-2)); | |
| display:grid;place-items:center;font-size:22px;box-shadow:0 6px 24px rgba(91,140,255,.35)} | |
| .brand h1{font-size:19px;margin:0;letter-spacing:.2px} | |
| .brand p{margin:2px 0 0;font-size:12.5px;color:var(--muted)} | |
| .links{display:flex;gap:8px;flex-wrap:wrap} | |
| .links a{font-size:12.5px;color:var(--muted);text-decoration:none;border:1px solid var(--edge); | |
| padding:6px 11px;border-radius:999px;transition:.15s} | |
| .links a:hover{color:var(--text);border-color:var(--accent);background:rgba(91,140,255,.08)} | |
| .layout{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:20px} | |
| @media (max-width:960px){.layout{grid-template-columns:1fr}} | |
| .card{background:linear-gradient(180deg,var(--panel),var(--panel-2)); | |
| border:1px solid var(--edge);border-radius:var(--radius);padding:16px} | |
| /* Stage */ | |
| .stage{position:relative;aspect-ratio:1280/704;background:#05070c;border-radius:12px; | |
| overflow:hidden;border:1px solid var(--edge);display:grid;place-items:center} | |
| .stage canvas{width:100%;height:100%;object-fit:cover;display:none;image-rendering:auto} | |
| .stage.playing canvas{display:block} | |
| .overlay{position:absolute;inset:0;display:grid;place-items:center;text-align:center; | |
| padding:24px;background:rgba(5,7,12,.55);backdrop-filter:blur(2px)} | |
| .overlay.hidden{display:none} | |
| .spinner{width:34px;height:34px;border-radius:50%;border:3px solid rgba(255,255,255,.15); | |
| border-top-color:var(--accent);animation:spin 1s linear infinite;margin:0 auto 14px} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .overlay h3{margin:0 0 6px;font-size:16px} | |
| .overlay p{margin:0;color:var(--muted);font-size:13px;max-width:420px} | |
| .hud{position:absolute;top:10px;left:10px;right:10px;display:flex;justify-content:space-between; | |
| pointer-events:none;font-size:12px} | |
| .chip{background:rgba(9,12,20,.7);border:1px solid var(--edge);border-radius:999px; | |
| padding:5px 11px;display:flex;align-items:center;gap:7px;backdrop-filter:blur(4px)} | |
| .dot{width:8px;height:8px;border-radius:50%;background:var(--muted)} | |
| .dot.playing{background:var(--good);box-shadow:0 0 8px var(--good)} | |
| .dot.loading{background:var(--warn);box-shadow:0 0 8px var(--warn);animation:pulse 1s infinite} | |
| .dot.error,.dot.ended{background:var(--bad)} | |
| @keyframes pulse{50%{opacity:.35}} | |
| .capture-hint{position:absolute;bottom:10px;left:50%;transform:translateX(-50%); | |
| background:rgba(9,12,20,.72);border:1px solid var(--edge);border-radius:999px; | |
| padding:6px 14px;font-size:12px;color:var(--muted);pointer-events:none;transition:.2s;opacity:0} | |
| .stage.playing .capture-hint{opacity:1} | |
| /* Uploader */ | |
| .drop{border:1.5px dashed var(--edge);border-radius:12px;padding:16px;text-align:center; | |
| cursor:pointer;transition:.15s;background:rgba(255,255,255,.015)} | |
| .drop:hover,.drop.drag{border-color:var(--accent);background:rgba(91,140,255,.07)} | |
| .drop .icon{font-size:26px} | |
| .drop .t{font-size:13.5px;margin-top:6px} | |
| .drop .s{font-size:11.5px;color:var(--muted);margin-top:2px} | |
| .thumb{display:none;margin-top:10px;border-radius:10px;overflow:hidden;border:1px solid var(--edge)} | |
| .thumb img{width:100%;display:block;max-height:170px;object-fit:cover} | |
| .thumb.show{display:block} | |
| /* Preset starting-world thumbnails */ | |
| .presets{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:10px} | |
| .preset{position:relative;border:1px solid var(--edge);border-radius:9px;overflow:hidden; | |
| cursor:pointer;background:#0d1220;aspect-ratio:16/9;transition:.15s} | |
| .preset:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(91,140,255,.25)} | |
| .preset.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(91,140,255,.45)} | |
| .preset img{width:100%;height:100%;object-fit:cover;display:block} | |
| .preset .pl{position:absolute;left:0;right:0;bottom:0;font-size:10px;color:#fff; | |
| padding:3px 5px;background:linear-gradient(0deg,rgba(5,7,12,.85),transparent); | |
| white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .or-sep{font-size:11px;color:var(--muted);text-align:center;margin:12px 0 8px; | |
| text-transform:uppercase;letter-spacing:.7px} | |
| .field{margin-top:14px} | |
| .label{font-size:11.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);margin-bottom:6px} | |
| textarea,input[type=number]{width:100%;background:#0d1220;color:var(--text);border:1px solid var(--edge); | |
| border-radius:10px;padding:10px 12px;font-size:13.5px;font-family:inherit;resize:vertical} | |
| textarea:focus,input:focus{outline:none;border-color:var(--accent)} | |
| .row{display:flex;gap:10px} | |
| .row>*{flex:1} | |
| .btns{display:flex;gap:10px;margin-top:16px} | |
| button{border:none;border-radius:10px;padding:11px 14px;font-size:13.5px;font-weight:600; | |
| cursor:pointer;font-family:inherit;transition:.15s;flex:1} | |
| .primary{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#fff; | |
| box-shadow:0 6px 20px rgba(91,140,255,.32)} | |
| .primary:hover{filter:brightness(1.07)} | |
| .ghost{background:transparent;color:var(--muted);border:1px solid var(--edge)} | |
| .ghost:hover{color:var(--text);border-color:var(--accent)} | |
| button:disabled{opacity:.45;cursor:not-allowed;filter:none} | |
| /* Controls section (below the stage) */ | |
| .controls-section{margin-top:20px} | |
| .controls-grid{display:grid;grid-template-columns:auto 1fr;gap:28px;align-items:center;margin-top:12px} | |
| @media (max-width:640px){.controls-grid{grid-template-columns:1fr;gap:20px}} | |
| /* Visual on-screen WASD keyboard */ | |
| .keyboard{display:grid;grid-template-columns:repeat(3,56px);grid-template-rows:repeat(2,56px); | |
| gap:8px;justify-content:start} | |
| .keycap{display:grid;place-items:center;border-radius:12px;background:#0d1220; | |
| border:1px solid var(--edge);font-size:18px;color:var(--muted);font-weight:700; | |
| transition:.08s;user-select:none} | |
| .keycap.spacer{background:transparent;border:none} | |
| .keycap.active{background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#fff; | |
| border-color:transparent;box-shadow:0 4px 16px rgba(91,140,255,.45);transform:translateY(1px)} | |
| /* WASD cross placement inside the 3x2 grid */ | |
| .keycap[data-k="W"]{grid-column:2;grid-row:1} | |
| .keycap[data-k="A"]{grid-column:1;grid-row:2} | |
| .keycap[data-k="S"]{grid-column:2;grid-row:2} | |
| .keycap[data-k="D"]{grid-column:3;grid-row:2} | |
| /* IJKL look cross placement inside its own 3x2 grid */ | |
| .keycap[data-k="I"]{grid-column:2;grid-row:1} | |
| .keycap[data-k="J"]{grid-column:1;grid-row:2} | |
| .keycap[data-k="K"]{grid-column:2;grid-row:2} | |
| .keycap[data-k="L"]{grid-column:3;grid-row:2} | |
| .look-info{font-size:12.5px;color:var(--muted);line-height:1.6} | |
| .look-info b{color:var(--text)} | |
| .look-badge{display:inline-flex;align-items:center;gap:6px;font-size:11.5px;color:var(--good); | |
| border:1px solid var(--edge);border-radius:999px;padding:4px 10px;margin-top:8px} | |
| .look-badge .d{width:7px;height:7px;border-radius:50%;background:var(--muted)} | |
| .look-badge.active .d{background:var(--good);box-shadow:0 0 8px var(--good)} | |
| .hint-line{font-size:11.5px;color:var(--muted);margin-top:14px;line-height:1.55} | |
| .active-tags{font-size:12px;color:var(--good);min-height:16px;margin-top:8px} | |
| .footnote{font-size:11px;color:var(--muted);margin-top:12px;line-height:1.5} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <header> | |
| <div class="brand"> | |
| <div class="logo">🌍</div> | |
| <div> | |
| <h1>ABot-World — Interactive World Rollout</h1> | |
| <p>Upload a starting frame, then steer a live action-conditioned world with WASD + IJKL.</p> | |
| </div> | |
| </div> | |
| <div class="links"> | |
| <a href="https://huggingface.co/acvlab/ABot-World-0-5B-LF" target="_blank">Model</a> | |
| <a href="https://amap-cvlab.github.io/ABot-World/" target="_blank">Project</a> | |
| <a href="https://github.com/amap-cvlab/ABot-World" target="_blank">Code</a> | |
| </div> | |
| </header> | |
| <div class="layout"> | |
| <!-- Stage --> | |
| <div class="card"> | |
| <div class="stage" id="stage" tabindex="0"> | |
| <canvas id="view" width="1280" height="704"></canvas> | |
| <div class="hud"> | |
| <div class="chip"><span class="dot" id="status-dot"></span><span id="status-label">Idle</span></div> | |
| <div class="chip"><span id="fps-value">0</span> fps · <span id="frame-value">0</span> blk</div> | |
| </div> | |
| <div class="capture-hint" id="capture-hint">Click to capture · WASD to move · IJKL to look · Esc to release</div> | |
| <div class="overlay" id="overlay"> | |
| <div> | |
| <div class="spinner" id="spinner" style="display:none"></div> | |
| <h3 id="overlay-title">Upload an image to begin</h3> | |
| <p id="overlay-sub">Pick a starting frame on the right, add a scene prompt, and press <b>Start world</b>.</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Controls (directly below the video output) --> | |
| <div class="controls-section"> | |
| <div class="controls-grid"> | |
| <div> | |
| <div class="label" style="margin-bottom:8px">Movement</div> | |
| <div class="keyboard" id="keys"> | |
| <span class="keycap" data-k="W">W</span> | |
| <span class="keycap" data-k="A">A</span> | |
| <span class="keycap" data-k="S">S</span> | |
| <span class="keycap" data-k="D">D</span> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="label" style="margin-bottom:8px">Look / pan</div> | |
| <div class="keyboard" id="look-keys"> | |
| <span class="keycap" data-k="I">I</span> | |
| <span class="keycap" data-k="J">J</span> | |
| <span class="keycap" data-k="K">K</span> | |
| <span class="keycap" data-k="L">L</span> | |
| </div> | |
| <div class="look-badge" id="look-badge"><span class="d"></span><span id="look-badge-text">Look idle</span></div> | |
| </div> | |
| </div> | |
| <div class="hint-line"> | |
| <b>W A S D</b> — move & turn (hold to steer the next block) · | |
| <b>I J K L</b> — look / pan. Click the video to capture input. | |
| </div> | |
| <div class="active-tags" id="active-tags"></div> | |
| </div> | |
| </div> | |
| <!-- Controls --> | |
| <div class="card"> | |
| <div class="label">Starting image (i2v seed)</div> | |
| <div class="presets" id="presets"></div> | |
| <div class="or-sep">or upload your own</div> | |
| <div class="drop" id="drop"> | |
| <div class="icon">🖼️</div> | |
| <div class="t">Drop an image or click to upload</div> | |
| <div class="s">PNG / JPG · becomes the world's first frame</div> | |
| </div> | |
| <input type="file" id="file-input" accept="image/*" style="display:none" /> | |
| <div class="thumb" id="thumb"><img id="thumb-img" alt="seed preview" /></div> | |
| <div class="field"> | |
| <div class="label">Scene prompt</div> | |
| <textarea id="prompt" rows="3">A realistic outdoor world scene with a navigable path, natural lighting, detailed ground texture, and stable forward motion.</textarea> | |
| </div> | |
| <div class="field row"> | |
| <div> | |
| <div class="label">Seed</div> | |
| <input type="number" id="seed" value="42" /> | |
| </div> | |
| </div> | |
| <div class="btns"> | |
| <button class="primary" id="start-btn" disabled>Start world</button> | |
| <button class="ghost" id="stop-btn" disabled>Stop</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| // One session id per tab keeps every server-side queue isolated per user. | |
| const SESSION_ID = (crypto.randomUUID && crypto.randomUUID()) || | |
| (`s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`); | |
| // Movement keys are driven by the keyboard (WASD); look/pan is driven by the | |
| // keyboard too (I J K L) — both are handled as held-key controls. | |
| const MOVE_KEYS = new Set(["W","A","S","D"]); | |
| const LOOK_KEYS = new Set(["I","J","K","L"]); | |
| const KEY_FROM_CODE = { | |
| KeyW:"W", KeyA:"A", KeyS:"S", KeyD:"D", | |
| KeyI:"I", KeyJ:"J", KeyK:"K", KeyL:"L", | |
| }; | |
| const state = { | |
| sessionId: SESSION_ID, | |
| client: null, | |
| ws: null, | |
| playing: false, | |
| capturing: false, | |
| pressed: new Set(), | |
| seedPath: "", | |
| prompt: "", | |
| }; | |
| // --- DOM --- | |
| const $ = (id) => document.getElementById(id); | |
| const stage = $("stage"), view = $("view"), overlay = $("overlay"); | |
| const viewCtx = view.getContext("2d", { alpha: false, desynchronized: true }); | |
| const spinner = $("spinner"), overlayTitle = $("overlay-title"), overlaySub = $("overlay-sub"); | |
| const statusDot = $("status-dot"), statusLabel = $("status-label"); | |
| const fpsValue = $("fps-value"), frameValue = $("frame-value"); | |
| const drop = $("drop"), fileInput = $("file-input"), thumb = $("thumb"), thumbImg = $("thumb-img"); | |
| const presetsEl = $("presets"); | |
| const promptEl = $("prompt"), seedEl = $("seed"); | |
| const startBtn = $("start-btn"), stopBtn = $("stop-btn"); | |
| const activeTags = $("active-tags"); | |
| const keycaps = Array.from(document.querySelectorAll(".keycap[data-k]")); | |
| const lookBadge = $("look-badge"), lookBadgeText = $("look-badge-text"); | |
| // --- UI helpers --- | |
| function setStatus(kind, label){ | |
| statusDot.className = "dot " + kind; | |
| statusLabel.textContent = label; | |
| } | |
| function showOverlay(title, sub, loading){ | |
| overlay.classList.remove("hidden"); | |
| overlayTitle.textContent = title; | |
| overlaySub.textContent = sub || ""; | |
| spinner.style.display = loading ? "block" : "none"; | |
| } | |
| function hideOverlay(){ overlay.classList.add("hidden"); } | |
| function setPlaying(on){ | |
| state.playing = on; | |
| stage.classList.toggle("playing", on); | |
| stopBtn.disabled = !on; | |
| } | |
| function refreshStartEnabled(){ | |
| startBtn.disabled = !state.seedPath || state.playing; | |
| } | |
| // --- Upload --- | |
| async function uploadFile(file){ | |
| if(!file || !file.type.startsWith("image/")){ return; } | |
| clearPresetSelection(); | |
| // Local preview | |
| const reader = new FileReader(); | |
| reader.onload = (e)=>{ thumbImg.src = e.target.result; thumb.classList.add("show"); }; | |
| reader.readAsDataURL(file); | |
| drop.querySelector(".t").textContent = "Uploading…"; | |
| const fd = new FormData(); | |
| fd.append("file", file, file.name || "seed.png"); | |
| try{ | |
| const res = await fetch(`${window.location.origin}/upload_seed`, { method:"POST", body:fd }); | |
| const data = await res.json(); | |
| if(data.seed_path){ | |
| state.seedPath = data.seed_path; | |
| drop.querySelector(".t").textContent = "Image ready ✓ — click to replace"; | |
| showOverlay("Ready to explore", "Press Start world to begin the rollout.", false); | |
| }else{ | |
| drop.querySelector(".t").textContent = "Upload failed — try another image"; | |
| } | |
| }catch(err){ | |
| console.error(err); | |
| drop.querySelector(".t").textContent = "Upload failed — try again"; | |
| } | |
| refreshStartEnabled(); | |
| } | |
| drop.addEventListener("click", ()=>fileInput.click()); | |
| fileInput.addEventListener("change", (e)=>uploadFile(e.target.files[0])); | |
| ["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,(e)=>{e.preventDefault();drop.classList.add("drag");})); | |
| ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,(e)=>{e.preventDefault();drop.classList.remove("drag");})); | |
| drop.addEventListener("drop",(e)=>{ if(e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]); }); | |
| // --- Preset starting-world thumbnails (seed rollout directly, no upload) --- | |
| function clearPresetSelection(){ | |
| presetsEl.querySelectorAll(".preset").forEach(p=>p.classList.remove("selected")); | |
| } | |
| async function pickPreset(name, el){ | |
| clearPresetSelection(); | |
| el.classList.add("selected"); | |
| // Show the chosen preset in the seed preview too. | |
| thumbImg.src = `${window.location.origin}/example_thumb?name=${encodeURIComponent(name)}`; | |
| thumb.classList.add("show"); | |
| drop.querySelector(".t").textContent = "Loading preset…"; | |
| try{ | |
| const res = await fetch(`${window.location.origin}/example_seed?name=${encodeURIComponent(name)}`); | |
| const data = await res.json(); | |
| if(data.seed_path){ | |
| state.seedPath = data.seed_path; | |
| drop.querySelector(".t").textContent = "Preset ready ✓ — or upload to replace"; | |
| showOverlay("Ready to explore", "Press Start world to begin the rollout.", false); | |
| }else{ | |
| drop.querySelector(".t").textContent = "Could not load preset — try another"; | |
| el.classList.remove("selected"); | |
| } | |
| }catch(err){ | |
| console.error(err); | |
| drop.querySelector(".t").textContent = "Could not load preset — try again"; | |
| el.classList.remove("selected"); | |
| } | |
| refreshStartEnabled(); | |
| } | |
| async function loadPresets(){ | |
| try{ | |
| const res = await fetch(`${window.location.origin}/example_seeds`); | |
| const data = await res.json(); | |
| (data.examples || []).forEach(ex=>{ | |
| const div = document.createElement("div"); | |
| div.className = "preset"; | |
| div.title = ex.label || ex.name; | |
| div.innerHTML = | |
| `<img loading="lazy" alt="${ex.label || ex.name}" ` + | |
| `src="${window.location.origin}/example_thumb?name=${encodeURIComponent(ex.name)}" />` + | |
| `<span class="pl">${ex.label || ex.name}</span>`; | |
| div.addEventListener("click", ()=>pickPreset(ex.name, div)); | |
| presetsEl.appendChild(div); | |
| }); | |
| }catch(err){ console.error("presets load failed", err); } | |
| } | |
| // --- Gradio client (auto-forwards the HF iframe x-api-token for quota) --- | |
| async function initClient(){ | |
| if(!state.client) state.client = await Client.connect(window.location.origin); | |
| } | |
| // --- Frame decode + jitter buffer + steady render clock --- | |
| // Rendering is DECOUPLED from network arrival. WebSocket messages only decode | |
| // frames into a small jitter buffer; a steady render clock (requestAnimationFrame) | |
| // then pulls the freshest decoded frame at a smoothed target interval and paints | |
| // it. This absorbs network jitter and the server's block-boundary bursts so the | |
| // visible cadence stays even instead of "burst of N frames, then a gap". | |
| // | |
| // The buffer is intentionally shallow (JITTER_MAX): if frames arrive faster than | |
| // we render, older ones are dropped (coalesced) rather than queued, so we never | |
| // accumulate buffering delay that would later flush as a burst of stale frames. | |
| const JITTER_MAX = 4; // max decoded frames held before we drop-oldest | |
| const JITTER_MIN = 2; // frames to buffer before the render clock starts | |
| // (a 2-frame lead absorbs network gaps so the | |
| // render clock rarely starves between bursts) | |
| let jitterBuf = []; // [{bitmap, blk, fps}] decoded, awaiting display | |
| let renderPrimed = false; // becomes true once JITTER_MIN frames buffered | |
| let renderInterval = 1000 / 16; // ms between paints; adapted to server fps (EMA) | |
| let lastPaint = 0; // performance.now() of last painted frame | |
| let lastArrival = 0; // performance.now() of last WS frame (for interval est.) | |
| let arrivalEma = 0; // EMA of inter-arrival gap (ms) | |
| async function ingestFrame(bytes, blk, fps){ | |
| // Estimate the true incoming cadence from arrival timing and adapt the render | |
| // interval toward it (bounded), so the steady clock matches the real fps. | |
| const now = performance.now(); | |
| if(lastArrival){ | |
| const gap = now - lastArrival; | |
| arrivalEma = arrivalEma ? (0.2 * gap + 0.8 * arrivalEma) : gap; | |
| const tgt = Math.min(200, Math.max(20, arrivalEma)); // clamp 5–50 fps | |
| renderInterval = 0.2 * tgt + 0.8 * renderInterval; | |
| } | |
| lastArrival = now; | |
| let bitmap = null; | |
| try{ | |
| bitmap = await createImageBitmap(new Blob([bytes], {type:"image/jpeg"})); | |
| }catch(err){ | |
| // Fallback path for browsers without createImageBitmap JPEG support: | |
| // paint straight to canvas (bypasses buffer but keeps the stream alive). | |
| await paintViaImage(bytes, blk, fps); | |
| return; | |
| } | |
| jitterBuf.push({ bitmap, blk, fps }); | |
| // Drop-oldest to keep the buffer shallow (avoid stale-frame burst on flush). | |
| while(jitterBuf.length > JITTER_MAX){ | |
| const stale = jitterBuf.shift(); | |
| if(stale.bitmap && stale.bitmap.close) stale.bitmap.close(); | |
| } | |
| if(!renderPrimed && jitterBuf.length >= JITTER_MIN){ | |
| renderPrimed = true; | |
| lastPaint = performance.now() - renderInterval; // paint first frame promptly | |
| } | |
| } | |
| function paintBitmap(entry){ | |
| viewCtx.drawImage(entry.bitmap, 0, 0, view.width, view.height); | |
| if(entry.bitmap && entry.bitmap.close) entry.bitmap.close(); | |
| if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); } | |
| frameValue.textContent = entry.blk; | |
| fpsValue.textContent = entry.fps.toFixed(1); | |
| } | |
| // Steady render clock: runs continuously via rAF while playing; pulls at most one | |
| // buffered frame per render-interval so display timing is even, not arrival-tied. | |
| function renderClock(){ | |
| requestAnimationFrame(renderClock); | |
| if(!renderPrimed || jitterBuf.length === 0) return; | |
| const now = performance.now(); | |
| if(now - lastPaint < renderInterval) return; // not yet this frame's slot | |
| const entry = jitterBuf.shift(); // freshest frames kept; oldest painted in order | |
| lastPaint = now; | |
| paintBitmap(entry); | |
| } | |
| requestAnimationFrame(renderClock); | |
| function resetJitterBuffer(){ | |
| for(const e of jitterBuf){ if(e.bitmap && e.bitmap.close) e.bitmap.close(); } | |
| jitterBuf = []; | |
| renderPrimed = false; | |
| lastArrival = 0; | |
| arrivalEma = 0; | |
| } | |
| function paintViaImage(bytes, blk, fps){ | |
| return new Promise((resolve)=>{ | |
| const url = URL.createObjectURL(new Blob([bytes], {type:"image/jpeg"})); | |
| const im = new Image(); | |
| im.onload = ()=>{ | |
| viewCtx.drawImage(im, 0, 0, view.width, view.height); | |
| URL.revokeObjectURL(url); | |
| if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); } | |
| if(typeof blk !== "undefined") frameValue.textContent = blk; | |
| if(typeof fps !== "undefined") fpsValue.textContent = fps.toFixed(1); | |
| resolve(true); | |
| }; | |
| im.onerror = ()=>{ URL.revokeObjectURL(url); resolve(false); }; | |
| im.src = url; | |
| }); | |
| } | |
| // --- WebSocket frame stream --- | |
| function connectWS(){ | |
| const proto = location.protocol === "https:" ? "wss:" : "ws:"; | |
| const ws = new WebSocket(`${proto}//${location.host}/ws?session_id=${encodeURIComponent(state.sessionId)}`); | |
| ws.binaryType = "arraybuffer"; | |
| ws.onmessage = (e)=>{ | |
| if(e.data instanceof ArrayBuffer){ | |
| const dv = new DataView(e.data); | |
| const blk = dv.getUint32(0); | |
| const fps = dv.getUint32(4)/10; | |
| // Decode into the jitter buffer; the steady render clock paints on its own | |
| // cadence, so display timing is decoupled from network arrival jitter. | |
| ingestFrame(e.data.slice(8), blk, fps); | |
| }else{ | |
| try{ | |
| const msg = JSON.parse(e.data); | |
| if(msg.type === "status"){ overlayTitle.textContent = msg.message; } | |
| else if(msg.type === "session_ended"){ | |
| setStatus("ended","Session ended"); setPlaying(false); | |
| showOverlay("Session ended","GPU time limit reached — press Start world to continue.",false); | |
| }else if(msg.type === "error"){ | |
| setStatus("error","Error"); setPlaying(false); | |
| showOverlay("Something went wrong", msg.message || "Unknown error", false); | |
| } | |
| }catch{} | |
| } | |
| }; | |
| ws.onclose = ()=>{ if(state.playing) setTimeout(connectWS, 1000); }; | |
| state.ws = ws; | |
| } | |
| // --- Control loop: send held keys ~10x/s --- | |
| let ctrlTimer = null; | |
| function startCtrlLoop(){ | |
| if(ctrlTimer) return; | |
| ctrlTimer = setInterval(()=>{ | |
| if(state.ws?.readyState === WebSocket.OPEN && state.playing){ | |
| state.ws.send(JSON.stringify({ | |
| type:"control", | |
| buttons: Array.from(state.pressed), | |
| prompt: state.prompt, | |
| })); | |
| } | |
| }, 100); | |
| } | |
| function stopCtrlLoop(){ if(ctrlTimer){ clearInterval(ctrlTimer); ctrlTimer = null; } } | |
| function renderKeys(){ | |
| // Highlight the on-screen WASD + IJKL keys that are currently held. | |
| keycaps.forEach(c=>c.classList.toggle("active", state.pressed.has(c.dataset.k))); | |
| // Look/pan status badge reflects the held look keys (I J K L). | |
| const look = Array.from(state.pressed).filter(k=>LOOK_KEYS.has(k)); | |
| const looking = look.length > 0; | |
| lookBadge.classList.toggle("active", looking); | |
| const LOOK_LABEL = { I:"up", K:"down", J:"left", L:"right" }; | |
| lookBadgeText.textContent = looking | |
| ? "Looking " + look.map(k=>LOOK_LABEL[k]).join(" + ") | |
| : (state.capturing ? "Look ready" : "Look idle"); | |
| const held = Array.from(state.pressed); | |
| activeTags.textContent = held.length ? "Active: " + held.join(" + ") : ""; | |
| } | |
| // --- Keyboard capture: WASD (move) + IJKL (look/pan) --- | |
| // Clicking the stage captures input; held keys are sent ~10x/s to the server. | |
| function requestCapture(){ | |
| state.capturing = true; | |
| stage.focus(); | |
| renderKeys(); | |
| } | |
| stage.addEventListener("click", requestCapture); | |
| document.addEventListener("keydown",(e)=>{ | |
| if(!state.capturing) return; | |
| if(e.code === "Escape"){ | |
| state.capturing=false; state.pressed.clear(); | |
| renderKeys(); return; | |
| } | |
| const k = KEY_FROM_CODE[e.code]; | |
| if(k && (MOVE_KEYS.has(k) || LOOK_KEYS.has(k))){ e.preventDefault(); state.pressed.add(k); renderKeys(); } | |
| }); | |
| document.addEventListener("keyup",(e)=>{ | |
| const k = KEY_FROM_CODE[e.code]; | |
| if(k){ state.pressed.delete(k); renderKeys(); } | |
| }); | |
| promptEl.addEventListener("input", ()=>{ state.prompt = promptEl.value; }); | |
| // --- Start / Stop --- | |
| async function startWorld(){ | |
| if(!state.seedPath) return; | |
| state.prompt = promptEl.value; | |
| await initClient(); | |
| setStatus("loading","Starting…"); | |
| showOverlay("Initializing GPU & world…","Requesting a ZeroGPU slot — first run can take a moment.", true); | |
| startBtn.disabled = true; | |
| connectWS(); | |
| startCtrlLoop(); | |
| try{ | |
| await state.client.predict("/start_game", { | |
| session_id: state.sessionId, | |
| seed_path: state.seedPath, | |
| prompt: state.prompt || "", | |
| seed: parseInt(seedEl.value || "42", 10), | |
| }); | |
| // Auto-capture keyboard so the user can steer immediately. | |
| state.capturing = true; stage.focus(); | |
| }catch(err){ | |
| console.error("start failed", err); | |
| setStatus("error","Failed to start"); | |
| showOverlay("Failed to start", String(err?.message || err), false); | |
| startBtn.disabled = false; | |
| } | |
| } | |
| async function stopWorld(){ | |
| try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{} | |
| if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; } | |
| resetJitterBuffer(); | |
| stopCtrlLoop(); | |
| setPlaying(false); | |
| state.pressed.clear(); renderKeys(); | |
| setStatus("idle","Idle"); | |
| showOverlay("Stopped","Press Start world to roll out again.", false); | |
| refreshStartEnabled(); | |
| } | |
| startBtn.addEventListener("click", startWorld); | |
| stopBtn.addEventListener("click", stopWorld); | |
| // --- Init --- | |
| setStatus("idle","Idle"); | |
| state.prompt = promptEl.value; | |
| loadPresets(); | |
| initClient().catch(console.error); | |
| </script> | |
| </body> | |
| </html> | |