multimodalart HF Staff commited on
Commit
f3da730
·
verified ·
1 Parent(s): 9075d2d

Add client-side jitter buffer + steady render clock for smooth playback

Browse files
Files changed (1) hide show
  1. index.html +90 -39
index.html CHANGED
@@ -352,47 +352,99 @@ async function initClient(){
352
  if(!state.client) state.client = await Client.connect(window.location.origin);
353
  }
354
 
355
- // --- Frame decode + paint ---
356
- // Decode each incoming JPEG and paint it to the canvas as soon as it is ready.
357
- // We always keep only the FRESHEST pending frame (coalesce) so a slow decode
358
- // can never build up a backlog / play stale buffered frames the visible
359
- // output tracks the real generation rate reported by the fps counter.
360
- let pendingFrame = null; // {bytes, blk, fps} newest arrived, not yet decoded
361
- let decoding = false;
362
-
363
- async function drainFrames(){
364
- if(decoding) return;
365
- decoding = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  try{
367
- while(pendingFrame){
368
- const { bytes, blk, fps } = pendingFrame;
369
- pendingFrame = null; // grab the freshest, drop anything older
370
- let painted = false;
371
- try{
372
- const bitmap = await createImageBitmap(new Blob([bytes], {type:"image/jpeg"}));
373
- viewCtx.drawImage(bitmap, 0, 0, view.width, view.height);
374
- if(bitmap.close) bitmap.close();
375
- painted = true;
376
- }catch(err){
377
- // Fallback for browsers without createImageBitmap JPEG support.
378
- painted = await paintViaImage(bytes);
379
- }
380
- if(painted){
381
- if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
382
- frameValue.textContent = blk;
383
- fpsValue.textContent = fps.toFixed(1);
384
- }
385
- }
386
- }finally{
387
- decoding = false;
388
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  }
390
 
391
- function paintViaImage(bytes){
392
  return new Promise((resolve)=>{
393
  const url = URL.createObjectURL(new Blob([bytes], {type:"image/jpeg"}));
394
  const im = new Image();
395
- im.onload = ()=>{ viewCtx.drawImage(im, 0, 0, view.width, view.height); URL.revokeObjectURL(url); resolve(true); };
 
 
 
 
 
 
 
396
  im.onerror = ()=>{ URL.revokeObjectURL(url); resolve(false); };
397
  im.src = url;
398
  });
@@ -408,10 +460,9 @@ function connectWS(){
408
  const dv = new DataView(e.data);
409
  const blk = dv.getUint32(0);
410
  const fps = dv.getUint32(4)/10;
411
- // Store the newest frame's bytes and decode/paint immediately (coalescing
412
- // any frame that hasn't been painted yet), so display keeps pace with fps.
413
- pendingFrame = { bytes: e.data.slice(8), blk, fps };
414
- drainFrames();
415
  }else{
416
  try{
417
  const msg = JSON.parse(e.data);
@@ -498,7 +549,7 @@ async function startWorld(){
498
  async function stopWorld(){
499
  try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{}
500
  if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; }
501
- pendingFrame = null;
502
  stopCtrlLoop();
503
  setPlaying(false);
504
  state.pressed.clear(); renderKeys();
 
352
  if(!state.client) state.client = await Client.connect(window.location.origin);
353
  }
354
 
355
+ // --- Frame decode + jitter buffer + steady render clock ---
356
+ // Rendering is DECOUPLED from network arrival. WebSocket messages only decode
357
+ // frames into a small jitter buffer; a steady render clock (requestAnimationFrame)
358
+ // then pulls the freshest decoded frame at a smoothed target interval and paints
359
+ // it. This absorbs network jitter and the server's block-boundary bursts so the
360
+ // visible cadence stays even instead of "burst of N frames, then a gap".
361
+ //
362
+ // The buffer is intentionally shallow (JITTER_MAX): if frames arrive faster than
363
+ // we render, older ones are dropped (coalesced) rather than queued, so we never
364
+ // accumulate buffering delay that would later flush as a burst of stale frames.
365
+ const JITTER_MAX = 3; // max decoded frames held before we drop-oldest
366
+ const JITTER_MIN = 1; // frames to buffer before the render clock starts
367
+ let jitterBuf = []; // [{bitmap, blk, fps}] decoded, awaiting display
368
+ let renderPrimed = false; // becomes true once JITTER_MIN frames buffered
369
+ let renderInterval = 1000 / 16; // ms between paints; adapted to server fps (EMA)
370
+ let lastPaint = 0; // performance.now() of last painted frame
371
+ let lastArrival = 0; // performance.now() of last WS frame (for interval est.)
372
+ let arrivalEma = 0; // EMA of inter-arrival gap (ms)
373
+
374
+ async function ingestFrame(bytes, blk, fps){
375
+ // Estimate the true incoming cadence from arrival timing and adapt the render
376
+ // interval toward it (bounded), so the steady clock matches the real fps.
377
+ const now = performance.now();
378
+ if(lastArrival){
379
+ const gap = now - lastArrival;
380
+ arrivalEma = arrivalEma ? (0.2 * gap + 0.8 * arrivalEma) : gap;
381
+ const tgt = Math.min(200, Math.max(20, arrivalEma)); // clamp 5–50 fps
382
+ renderInterval = 0.2 * tgt + 0.8 * renderInterval;
383
+ }
384
+ lastArrival = now;
385
+
386
+ let bitmap = null;
387
  try{
388
+ bitmap = await createImageBitmap(new Blob([bytes], {type:"image/jpeg"}));
389
+ }catch(err){
390
+ // Fallback path for browsers without createImageBitmap JPEG support:
391
+ // paint straight to canvas (bypasses buffer but keeps the stream alive).
392
+ await paintViaImage(bytes, blk, fps);
393
+ return;
394
+ }
395
+ jitterBuf.push({ bitmap, blk, fps });
396
+ // Drop-oldest to keep the buffer shallow (avoid stale-frame burst on flush).
397
+ while(jitterBuf.length > JITTER_MAX){
398
+ const stale = jitterBuf.shift();
399
+ if(stale.bitmap && stale.bitmap.close) stale.bitmap.close();
 
 
 
 
 
 
 
 
 
400
  }
401
+ if(!renderPrimed && jitterBuf.length >= JITTER_MIN){
402
+ renderPrimed = true;
403
+ lastPaint = performance.now() - renderInterval; // paint first frame promptly
404
+ }
405
+ }
406
+
407
+ function paintBitmap(entry){
408
+ viewCtx.drawImage(entry.bitmap, 0, 0, view.width, view.height);
409
+ if(entry.bitmap && entry.bitmap.close) entry.bitmap.close();
410
+ if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
411
+ frameValue.textContent = entry.blk;
412
+ fpsValue.textContent = entry.fps.toFixed(1);
413
+ }
414
+
415
+ // Steady render clock: runs continuously via rAF while playing; pulls at most one
416
+ // buffered frame per render-interval so display timing is even, not arrival-tied.
417
+ function renderClock(){
418
+ requestAnimationFrame(renderClock);
419
+ if(!renderPrimed || jitterBuf.length === 0) return;
420
+ const now = performance.now();
421
+ if(now - lastPaint < renderInterval) return; // not yet this frame's slot
422
+ const entry = jitterBuf.shift(); // freshest frames kept; oldest painted in order
423
+ lastPaint = now;
424
+ paintBitmap(entry);
425
+ }
426
+ requestAnimationFrame(renderClock);
427
+
428
+ function resetJitterBuffer(){
429
+ for(const e of jitterBuf){ if(e.bitmap && e.bitmap.close) e.bitmap.close(); }
430
+ jitterBuf = [];
431
+ renderPrimed = false;
432
+ lastArrival = 0;
433
+ arrivalEma = 0;
434
  }
435
 
436
+ function paintViaImage(bytes, blk, fps){
437
  return new Promise((resolve)=>{
438
  const url = URL.createObjectURL(new Blob([bytes], {type:"image/jpeg"}));
439
  const im = new Image();
440
+ im.onload = ()=>{
441
+ viewCtx.drawImage(im, 0, 0, view.width, view.height);
442
+ URL.revokeObjectURL(url);
443
+ if(!state.playing){ hideOverlay(); setPlaying(true); setStatus("playing","Playing"); }
444
+ if(typeof blk !== "undefined") frameValue.textContent = blk;
445
+ if(typeof fps !== "undefined") fpsValue.textContent = fps.toFixed(1);
446
+ resolve(true);
447
+ };
448
  im.onerror = ()=>{ URL.revokeObjectURL(url); resolve(false); };
449
  im.src = url;
450
  });
 
460
  const dv = new DataView(e.data);
461
  const blk = dv.getUint32(0);
462
  const fps = dv.getUint32(4)/10;
463
+ // Decode into the jitter buffer; the steady render clock paints on its own
464
+ // cadence, so display timing is decoupled from network arrival jitter.
465
+ ingestFrame(e.data.slice(8), blk, fps);
 
466
  }else{
467
  try{
468
  const msg = JSON.parse(e.data);
 
549
  async function stopWorld(){
550
  try{ if(state.client) await state.client.predict("/stop_game", { session_id: state.sessionId }); }catch{}
551
  if(state.ws){ try{ state.ws.send(JSON.stringify({type:"stop"})); }catch{} state.ws.close(); state.ws=null; }
552
+ resetJitterBuffer();
553
  stopCtrlLoop();
554
  setPlaying(false);
555
  state.pressed.clear(); renderKeys();