Fix real-time frame render: decode+paint newest JPEG to canvas immediately (no img.src thrash/stale redraw/buffering)
Browse files- index.html +55 -10
index.html
CHANGED
|
@@ -39,8 +39,8 @@
|
|
| 39 |
/* Stage */
|
| 40 |
.stage{position:relative;aspect-ratio:1280/704;background:#05070c;border-radius:12px;
|
| 41 |
overflow:hidden;border:1px solid var(--edge);display:grid;place-items:center}
|
| 42 |
-
.stage
|
| 43 |
-
.stage.playing
|
| 44 |
.overlay{position:absolute;inset:0;display:grid;place-items:center;text-align:center;
|
| 45 |
padding:24px;background:rgba(5,7,12,.55);backdrop-filter:blur(2px)}
|
| 46 |
.overlay.hidden{display:none}
|
|
@@ -125,7 +125,7 @@
|
|
| 125 |
<!-- Stage -->
|
| 126 |
<div class="card">
|
| 127 |
<div class="stage" id="stage" tabindex="0">
|
| 128 |
-
<
|
| 129 |
<div class="hud">
|
| 130 |
<div class="chip"><span class="dot" id="status-dot"></span><span id="status-label">Idle</span></div>
|
| 131 |
<div class="chip"><span id="fps-value">0</span> fps · <span id="frame-value">0</span> blk</div>
|
|
@@ -223,6 +223,7 @@ const state = {
|
|
| 223 |
// --- DOM ---
|
| 224 |
const $ = (id) => document.getElementById(id);
|
| 225 |
const stage = $("stage"), view = $("view"), overlay = $("overlay");
|
|
|
|
| 226 |
const spinner = $("spinner"), overlayTitle = $("overlay-title"), overlaySub = $("overlay-sub");
|
| 227 |
const statusDot = $("status-dot"), statusLabel = $("status-label");
|
| 228 |
const fpsValue = $("fps-value"), frameValue = $("frame-value");
|
|
@@ -292,6 +293,52 @@ async function initClient(){
|
|
| 292 |
if(!state.client) state.client = await Client.connect(window.location.origin);
|
| 293 |
}
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
// --- WebSocket frame stream ---
|
| 296 |
function connectWS(){
|
| 297 |
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
@@ -302,13 +349,10 @@ function connectWS(){
|
|
| 302 |
const dv = new DataView(e.data);
|
| 303 |
const blk = dv.getUint32(0);
|
| 304 |
const fps = dv.getUint32(4)/10;
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
|
| 310 |
-
frameValue.textContent = blk;
|
| 311 |
-
fpsValue.textContent = fps.toFixed(1);
|
| 312 |
}else{
|
| 313 |
try{
|
| 314 |
const msg = JSON.parse(e.data);
|
|
@@ -395,6 +439,7 @@ async function startWorld(){
|
|
| 395 |
async function stopWorld(){
|
| 396 |
try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{}
|
| 397 |
if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; }
|
|
|
|
| 398 |
stopCtrlLoop();
|
| 399 |
setPlaying(false);
|
| 400 |
state.pressed.clear(); renderKeys();
|
|
|
|
| 39 |
/* Stage */
|
| 40 |
.stage{position:relative;aspect-ratio:1280/704;background:#05070c;border-radius:12px;
|
| 41 |
overflow:hidden;border:1px solid var(--edge);display:grid;place-items:center}
|
| 42 |
+
.stage canvas{width:100%;height:100%;object-fit:cover;display:none;image-rendering:auto}
|
| 43 |
+
.stage.playing canvas{display:block}
|
| 44 |
.overlay{position:absolute;inset:0;display:grid;place-items:center;text-align:center;
|
| 45 |
padding:24px;background:rgba(5,7,12,.55);backdrop-filter:blur(2px)}
|
| 46 |
.overlay.hidden{display:none}
|
|
|
|
| 125 |
<!-- Stage -->
|
| 126 |
<div class="card">
|
| 127 |
<div class="stage" id="stage" tabindex="0">
|
| 128 |
+
<canvas id="view" width="1280" height="704"></canvas>
|
| 129 |
<div class="hud">
|
| 130 |
<div class="chip"><span class="dot" id="status-dot"></span><span id="status-label">Idle</span></div>
|
| 131 |
<div class="chip"><span id="fps-value">0</span> fps · <span id="frame-value">0</span> blk</div>
|
|
|
|
| 223 |
// --- DOM ---
|
| 224 |
const $ = (id) => document.getElementById(id);
|
| 225 |
const stage = $("stage"), view = $("view"), overlay = $("overlay");
|
| 226 |
+
const viewCtx = view.getContext("2d", { alpha: false, desynchronized: true });
|
| 227 |
const spinner = $("spinner"), overlayTitle = $("overlay-title"), overlaySub = $("overlay-sub");
|
| 228 |
const statusDot = $("status-dot"), statusLabel = $("status-label");
|
| 229 |
const fpsValue = $("fps-value"), frameValue = $("frame-value");
|
|
|
|
| 293 |
if(!state.client) state.client = await Client.connect(window.location.origin);
|
| 294 |
}
|
| 295 |
|
| 296 |
+
// --- Frame decode + paint ---
|
| 297 |
+
// Decode each incoming JPEG and paint it to the canvas as soon as it is ready.
|
| 298 |
+
// We always keep only the FRESHEST pending frame (coalesce) so a slow decode
|
| 299 |
+
// can never build up a backlog / play stale buffered frames — the visible
|
| 300 |
+
// output tracks the real generation rate reported by the fps counter.
|
| 301 |
+
let pendingFrame = null; // {bytes, blk, fps} newest arrived, not yet decoded
|
| 302 |
+
let decoding = false;
|
| 303 |
+
|
| 304 |
+
async function drainFrames(){
|
| 305 |
+
if(decoding) return;
|
| 306 |
+
decoding = true;
|
| 307 |
+
try{
|
| 308 |
+
while(pendingFrame){
|
| 309 |
+
const { bytes, blk, fps } = pendingFrame;
|
| 310 |
+
pendingFrame = null; // grab the freshest, drop anything older
|
| 311 |
+
let painted = false;
|
| 312 |
+
try{
|
| 313 |
+
const bitmap = await createImageBitmap(new Blob([bytes], {type:"image/jpeg"}));
|
| 314 |
+
viewCtx.drawImage(bitmap, 0, 0, view.width, view.height);
|
| 315 |
+
if(bitmap.close) bitmap.close();
|
| 316 |
+
painted = true;
|
| 317 |
+
}catch(err){
|
| 318 |
+
// Fallback for browsers without createImageBitmap JPEG support.
|
| 319 |
+
painted = await paintViaImage(bytes);
|
| 320 |
+
}
|
| 321 |
+
if(painted){
|
| 322 |
+
if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
|
| 323 |
+
frameValue.textContent = blk;
|
| 324 |
+
fpsValue.textContent = fps.toFixed(1);
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
}finally{
|
| 328 |
+
decoding = false;
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
function paintViaImage(bytes){
|
| 333 |
+
return new Promise((resolve)=>{
|
| 334 |
+
const url = URL.createObjectURL(new Blob([bytes], {type:"image/jpeg"}));
|
| 335 |
+
const im = new Image();
|
| 336 |
+
im.onload = ()=>{ viewCtx.drawImage(im, 0, 0, view.width, view.height); URL.revokeObjectURL(url); resolve(true); };
|
| 337 |
+
im.onerror = ()=>{ URL.revokeObjectURL(url); resolve(false); };
|
| 338 |
+
im.src = url;
|
| 339 |
+
});
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
// --- WebSocket frame stream ---
|
| 343 |
function connectWS(){
|
| 344 |
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
|
|
| 349 |
const dv = new DataView(e.data);
|
| 350 |
const blk = dv.getUint32(0);
|
| 351 |
const fps = dv.getUint32(4)/10;
|
| 352 |
+
// Store the newest frame's bytes and decode/paint immediately (coalescing
|
| 353 |
+
// any frame that hasn't been painted yet), so display keeps pace with fps.
|
| 354 |
+
pendingFrame = { bytes: e.data.slice(8), blk, fps };
|
| 355 |
+
drainFrames();
|
|
|
|
|
|
|
|
|
|
| 356 |
}else{
|
| 357 |
try{
|
| 358 |
const msg = JSON.parse(e.data);
|
|
|
|
| 439 |
async function stopWorld(){
|
| 440 |
try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{}
|
| 441 |
if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; }
|
| 442 |
+
pendingFrame = null;
|
| 443 |
stopCtrlLoop();
|
| 444 |
setPlaying(false);
|
| 445 |
state.pressed.clear(); renderKeys();
|