624 lines
28 KiB
HTML
624 lines
28 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>DreamStack Stream — Interactive Receiver</title>
|
||
<meta name="description" content="Live interactive demo of the DreamStack Universal Bitstream Protocol — pixel streaming, delta compression, signal sync, and bidirectional input">
|
||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||
<style>
|
||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#050510;--surface:#0c0c1a;--surface2:#141428;--surface3:#1e1e36;
|
||
--border:#252545;--glow:#818cf822;
|
||
--text:#e2e8f0;--muted:#94a3b8;--dim:#64748b;
|
||
--accent:#818cf8;--accent2:#a78bfa;--green:#34d399;--yellow:#fbbf24;
|
||
--red:#f87171;--cyan:#22d3ee;--pink:#f472b6;--orange:#fb923c;
|
||
--gradient:linear-gradient(135deg,#818cf8,#6366f1);
|
||
--font:'Inter',system-ui,sans-serif;--mono:'JetBrains Mono',monospace;
|
||
}
|
||
html{font-size:14px}
|
||
body{background:var(--bg);color:var(--text);font-family:var(--font);min-height:100vh;overflow-x:hidden}
|
||
::selection{background:#818cf855}
|
||
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-thumb{background:#333;border-radius:3px}
|
||
|
||
.hero{text-align:center;padding:2rem 2rem 1rem;position:relative}
|
||
.hero::before{content:'';position:absolute;inset:0;background:radial-gradient(ellipse 70% 50% at 50% 0%,#6366f112,transparent);pointer-events:none}
|
||
.hero h1{font-size:2.4rem;font-weight:900;background:linear-gradient(135deg,#22d3ee,#818cf8,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-0.03em}
|
||
.badge{display:inline-block;background:linear-gradient(135deg,#22d3ee,#6366f1);color:#fff;font-size:.65rem;font-weight:700;padding:.15rem .6rem;border-radius:999px;margin-bottom:.5rem;letter-spacing:.05em}
|
||
.hero p{color:var(--muted);font-size:.9rem;max-width:620px;margin:0 auto}
|
||
|
||
/* Grid */
|
||
.grid{display:grid;grid-template-columns:1fr 340px;gap:1px;padding:1rem;max-width:1400px;margin:0 auto}
|
||
@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
||
|
||
/* Panels */
|
||
.panel{background:var(--surface);border:1px solid var(--border);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
|
||
.panel-h{display:flex;align-items:center;gap:.4rem;padding:.5rem .8rem;background:var(--surface2);border-bottom:1px solid var(--border);font-size:.7rem;font-weight:600;color:var(--muted)}
|
||
.panel-h .dot{width:7px;height:7px;border-radius:50%}
|
||
.panel-b{flex:1;overflow:auto;position:relative}
|
||
|
||
/* Canvas area */
|
||
.canvas-wrap{position:relative;background:#000;aspect-ratio:16/10;cursor:crosshair}
|
||
.canvas-wrap canvas{display:block;width:100%;height:100%;image-rendering:pixelated}
|
||
.canvas-overlay{position:absolute;top:8px;left:8px;display:flex;gap:6px;pointer-events:none}
|
||
.canvas-badge{background:#00000099;backdrop-filter:blur(8px);border:1px solid #ffffff15;border-radius:6px;padding:3px 8px;font-family:var(--mono);font-size:.6rem;color:#fff}
|
||
.canvas-badge.live{border-color:var(--red);color:var(--red)}
|
||
.mouse-pos{position:absolute;bottom:8px;right:8px;background:#00000099;backdrop-filter:blur(8px);border-radius:6px;padding:3px 8px;font-family:var(--mono);font-size:.6rem;color:var(--muted)}
|
||
|
||
/* Stats bar */
|
||
.stats-bar{display:grid;grid-template-columns:repeat(6,1fr);gap:1px;padding:.5rem .8rem;background:var(--surface2);border-top:1px solid var(--border)}
|
||
.stat-box{text-align:center}
|
||
.stat-v{font-size:.95rem;font-weight:800;font-family:var(--mono)}
|
||
.stat-l{font-size:.55rem;color:var(--dim);text-transform:uppercase;letter-spacing:.06em}
|
||
|
||
/* Sidebar */
|
||
.sidebar{display:flex;flex-direction:column;gap:1px}
|
||
|
||
/* Protocol inspector */
|
||
.proto-scroll{max-height:260px;overflow-y:auto;font-family:var(--mono);font-size:.68rem;padding:.5rem}
|
||
.proto-msg{display:flex;gap:6px;padding:3px 0;border-bottom:1px solid #ffffff06;align-items:center;animation:fadeSlide .2s ease}
|
||
.proto-dir{font-size:.55rem;font-weight:700;padding:1px 4px;border-radius:3px;flex-shrink:0}
|
||
.proto-out{background:#818cf822;color:var(--accent)}
|
||
.proto-in{background:#34d39922;color:var(--green)}
|
||
.proto-type{color:var(--cyan);min-width:85px}
|
||
.proto-seq{color:var(--dim)}
|
||
.proto-size{color:var(--muted);margin-left:auto}
|
||
@keyframes fadeSlide{from{opacity:0;transform:translateX(-6px)}to{opacity:1;transform:translateX(0)}}
|
||
|
||
/* Hex viewer */
|
||
.hex-view{font-family:var(--mono);font-size:.62rem;padding:.5rem;color:var(--muted);line-height:1.5;max-height:140px;overflow-y:auto}
|
||
.hex-offset{color:var(--dim)}
|
||
.hex-byte{color:var(--accent)}
|
||
.hex-header{color:var(--cyan)}
|
||
.hex-payload{color:var(--green)}
|
||
|
||
/* Controls */
|
||
.controls{padding:.6rem .8rem;display:flex;flex-wrap:wrap;gap:.4rem}
|
||
.btn{font-family:var(--font);font-size:.68rem;font-weight:600;padding:.4rem .8rem;border:1px solid var(--border);border-radius:6px;background:var(--surface2);color:var(--text);cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:.3rem}
|
||
.btn:hover{background:var(--surface3);border-color:var(--accent)}
|
||
.btn.active{background:var(--accent);border-color:var(--accent);color:#fff}
|
||
.btn-sm{padding:.25rem .5rem;font-size:.6rem}
|
||
|
||
/* Quality indicator */
|
||
.quality{display:flex;gap:4px;padding:.5rem .8rem;background:var(--surface2);border-top:1px solid var(--border)}
|
||
.q-bar{flex:1;height:6px;border-radius:3px;background:var(--surface3)}
|
||
.q-fill{height:100%;border-radius:3px;transition:width .3s}
|
||
|
||
/* Header diagram */
|
||
.header-dia{padding:.6rem;font-family:var(--mono);font-size:.62rem;line-height:1.6;background:var(--surface2);border-top:1px solid var(--border)}
|
||
.hdr-field{display:inline-block;padding:1px 5px;border-radius:3px;margin:0 1px}
|
||
.hdr-type{background:#22d3ee18;color:var(--cyan)}
|
||
.hdr-flags{background:#fbbf2418;color:var(--yellow)}
|
||
.hdr-seq{background:#818cf818;color:var(--accent)}
|
||
.hdr-ts{background:#f4728618;color:var(--pink)}
|
||
.hdr-dim{background:#34d39918;color:var(--green)}
|
||
.hdr-len{background:#fb923c18;color:var(--orange)}
|
||
|
||
/* Compression chart */
|
||
.comp-chart{display:flex;align-items:flex-end;gap:2px;height:60px;padding:.5rem .8rem}
|
||
.comp-bar{flex:1;background:var(--accent);border-radius:2px 2px 0 0;min-height:2px;transition:height .2s;position:relative}
|
||
.comp-bar::after{content:attr(data-ratio);position:absolute;top:-14px;left:50%;transform:translateX(-50%);font-size:.5rem;color:var(--muted);white-space:nowrap}
|
||
|
||
/* Signal graph */
|
||
.signal-list{padding:.5rem .8rem;font-family:var(--mono);font-size:.68rem}
|
||
.sig-row{display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid #ffffff06}
|
||
.sig-name{color:var(--cyan)}
|
||
.sig-val{color:var(--green)}
|
||
.sig-changed{animation:sigPulse .4s ease}
|
||
@keyframes sigPulse{0%{color:var(--yellow)}100%{color:var(--green)}}
|
||
|
||
footer{text-align:center;padding:1.5rem;color:var(--dim);font-size:.65rem}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="hero">
|
||
<div class="badge">UNIVERSAL BITSTREAM PROTOCOL v12</div>
|
||
<h1>ds-stream Interactive Receiver</h1>
|
||
<p>Live pixel streaming with XOR+RLE delta compression, signal sync, adaptive quality, and bidirectional input — all in the browser</p>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<!-- Main: Canvas + Stats -->
|
||
<div style="display:flex;flex-direction:column;gap:1px">
|
||
<div class="panel">
|
||
<div class="panel-h">
|
||
<div class="dot" style="background:var(--red)"></div>
|
||
<div class="dot" style="background:var(--yellow)"></div>
|
||
<div class="dot" style="background:var(--green)"></div>
|
||
<span>receiver viewport</span>
|
||
<span style="margin-left:auto" id="resolution">320×200</span>
|
||
<span id="stream-status" style="color:var(--green)">● LIVE</span>
|
||
</div>
|
||
<div class="panel-b">
|
||
<div class="canvas-wrap" id="canvas-wrap">
|
||
<canvas id="display" width="320" height="200"></canvas>
|
||
<div class="canvas-overlay">
|
||
<div class="canvas-badge live" id="badge-live">● LIVE</div>
|
||
<div class="canvas-badge" id="badge-fps">0 FPS</div>
|
||
<div class="canvas-badge" id="badge-frame">K #0</div>
|
||
</div>
|
||
<div class="mouse-pos" id="mouse-pos">0, 0</div>
|
||
</div>
|
||
</div>
|
||
<div class="stats-bar">
|
||
<div class="stat-box"><div class="stat-v" id="s-fps" style="color:var(--green)">0</div><div class="stat-l">FPS</div></div>
|
||
<div class="stat-box"><div class="stat-v" id="s-frames" style="color:var(--cyan)">0</div><div class="stat-l">Frames</div></div>
|
||
<div class="stat-box"><div class="stat-v" id="s-bytes" style="color:var(--accent)">0</div><div class="stat-l">KB Sent</div></div>
|
||
<div class="stat-box"><div class="stat-v" id="s-ratio" style="color:var(--yellow)">0%</div><div class="stat-l">Savings</div></div>
|
||
<div class="stat-box"><div class="stat-v" id="s-rtt" style="color:var(--pink)">0ms</div><div class="stat-l">RTT</div></div>
|
||
<div class="stat-box"><div class="stat-v" id="s-quality" style="color:var(--green)">Full</div><div class="stat-l">Quality</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Controls -->
|
||
<div class="panel">
|
||
<div class="panel-h"><span>stream controls</span></div>
|
||
<div class="controls">
|
||
<button class="btn active" id="btn-play" onclick="toggleStream()">⏸ Pause</button>
|
||
<button class="btn" onclick="sendKeyframe()">🔑 Force Keyframe</button>
|
||
<button class="btn" onclick="cycleFPS()">🎯 <span id="fps-label">30 FPS</span></button>
|
||
<button class="btn" onclick="cycleScene()">🎬 <span id="scene-label">Waves</span></button>
|
||
<button class="btn" onclick="toggleDelta()">📐 <span id="delta-label">Delta: ON</span></button>
|
||
<button class="btn" onclick="toggleSignals()">📡 <span id="sig-label">Signals: ON</span></button>
|
||
<button class="btn btn-sm" onclick="simulateLatency()">🌐 +100ms RTT</button>
|
||
<button class="btn btn-sm" onclick="resetStats()">↻ Reset</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Compression chart -->
|
||
<div class="panel">
|
||
<div class="panel-h"><span>compression ratio per frame</span><span style="margin-left:auto" id="avg-ratio">avg: 0%</span></div>
|
||
<div class="comp-chart" id="comp-chart"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sidebar -->
|
||
<div class="sidebar">
|
||
<!-- Header Format -->
|
||
<div class="panel">
|
||
<div class="panel-h"><span>16-byte header format</span></div>
|
||
<div class="header-dia" id="header-dia">
|
||
<div style="margin-bottom:4px;color:var(--muted)">┌──────┬───────┬──────┬───────────┬───────┬────────┬────────┐</div>
|
||
<div>│<span class="hdr-field hdr-type">type</span>│<span class="hdr-field hdr-flags">flags</span>│<span class="hdr-field hdr-seq">seq</span>│<span class="hdr-field hdr-ts">timestamp</span>│<span class="hdr-field hdr-dim">width</span>│<span class="hdr-field hdr-dim">height</span>│<span class="hdr-field hdr-len">length</span>│</div>
|
||
<div style="margin-top:4px;color:var(--muted)">│ <span style="color:var(--cyan)">u8</span> │ <span style="color:var(--yellow)">u8</span> │ <span style="color:var(--accent)">u16</span> │ <span style="color:var(--pink)">u32</span> │ <span style="color:var(--green)">u16</span> │ <span style="color:var(--green)">u16</span> │ <span style="color:var(--orange)">u32</span> │</div>
|
||
<div style="margin-top:4px;color:var(--muted)">└──────┴───────┴──────┴───────────┴───────┴────────┴────────┘</div>
|
||
<div id="hdr-live" style="margin-top:6px;color:var(--text)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Protocol Inspector -->
|
||
<div class="panel" style="flex:1">
|
||
<div class="panel-h"><span>protocol inspector</span><span style="margin-left:auto" id="msg-count">0 msgs</span></div>
|
||
<div class="proto-scroll" id="proto-log"></div>
|
||
</div>
|
||
|
||
<!-- Hex View -->
|
||
<div class="panel">
|
||
<div class="panel-h"><span>last frame hex dump</span></div>
|
||
<div class="hex-view" id="hex-view">Waiting for frames...</div>
|
||
</div>
|
||
|
||
<!-- Signals -->
|
||
<div class="panel">
|
||
<div class="panel-h"><span>signal state</span><span style="margin-left:auto" id="sig-count">0 signals</span></div>
|
||
<div class="signal-list" id="signal-list"></div>
|
||
</div>
|
||
|
||
<!-- Quality -->
|
||
<div class="panel">
|
||
<div class="panel-h"><span>adaptive quality</span></div>
|
||
<div class="quality">
|
||
<div class="q-bar"><div class="q-fill" id="q-fill" style="width:100%;background:var(--green)"></div></div>
|
||
</div>
|
||
<div style="padding:.3rem .8rem;font-size:.6rem;color:var(--muted);display:flex;justify-content:space-between">
|
||
<span>Full (RTT<50ms)</span><span>Reduced</span><span>Minimal</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer>DreamStack Universal Bitstream Protocol v12 — ds-stream · 16-byte headers · XOR+RLE delta · Adaptive quality · Stream mux · 24 frame types</footer>
|
||
|
||
<script>
|
||
// ─── Protocol Constants ───
|
||
const HEADER_SIZE = 16;
|
||
const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02, FLAG_COMPRESSED = 0x04;
|
||
const FRAME_TYPES = {
|
||
0x01:'Pixels', 0x02:'CompressedPx', 0x03:'DeltaPixels',
|
||
0x10:'AudioPCM', 0x11:'AudioOpus',
|
||
0x20:'Haptic', 0x21:'Actuator', 0x22:'LedMatrix',
|
||
0x30:'SignalSync', 0x31:'SignalDiff', 0x32:'SchemaAnnounce', 0x33:'SubscribeFilter',
|
||
0x40:'NeuralFrame', 0x41:'NeuralAudio', 0x42:'NeuralActuator', 0x43:'NeuralLatent',
|
||
0x50:'StateSync', 0x51:'Replay', 0x52:'Compressed',
|
||
0xF0:'Keyframe', 0x0F:'Auth', 0xFD:'Ack', 0xFE:'Ping', 0xFF:'End'
|
||
};
|
||
|
||
// ─── State ───
|
||
const W = 320, H = 200;
|
||
const canvas = document.getElementById('display');
|
||
const ctx = canvas.getContext('2d');
|
||
let running = true, useDelta = true, showSignals = true;
|
||
let seq = 0, frameCount = 0, totalBytes = 0, msgCount = 0;
|
||
let targetFPS = 30, currentScene = 0;
|
||
const scenes = ['Waves','Plasma','Rain','Matrix'];
|
||
let previousFrame = null, rttMs = 12;
|
||
let fpsCounter = 0, lastFpsTick = performance.now();
|
||
let compressionHistory = [];
|
||
let signals = { count: 0, time: 0, mouse_x: 0, mouse_y: 0, fps: 30, scene: 'waves', quality: 'full' };
|
||
|
||
// ─── Header encode/decode ───
|
||
function encodeHeader(type, flags, seq, ts, w, h, len) {
|
||
const buf = new ArrayBuffer(HEADER_SIZE);
|
||
const dv = new DataView(buf);
|
||
dv.setUint8(0, type); dv.setUint8(1, flags);
|
||
dv.setUint16(2, seq, true); dv.setUint32(4, ts, true);
|
||
dv.setUint16(8, w, true); dv.setUint16(10, h, true);
|
||
dv.setUint32(12, len, true);
|
||
return new Uint8Array(buf);
|
||
}
|
||
|
||
function decodeHeader(buf) {
|
||
const dv = new DataView(buf.buffer, buf.byteOffset);
|
||
return {
|
||
type: dv.getUint8(0), flags: dv.getUint8(1),
|
||
seq: dv.getUint16(2, true), timestamp: dv.getUint32(4, true),
|
||
width: dv.getUint16(8, true), height: dv.getUint16(10, true),
|
||
length: dv.getUint32(12, true)
|
||
};
|
||
}
|
||
|
||
// ─── XOR Delta ───
|
||
function computeDelta(current, previous) {
|
||
const delta = new Uint8Array(current.length);
|
||
for (let i = 0; i < current.length; i++) delta[i] = current[i] ^ previous[i];
|
||
return delta;
|
||
}
|
||
|
||
function applyDelta(previous, delta) {
|
||
const out = new Uint8Array(previous.length);
|
||
for (let i = 0; i < previous.length; i++) out[i] = previous[i] ^ delta[i];
|
||
return out;
|
||
}
|
||
|
||
// ─── RLE ───
|
||
function rleEncode(data) {
|
||
const out = [];
|
||
let i = 0;
|
||
while (i < data.length) {
|
||
if (data[i] === 0) {
|
||
let start = i;
|
||
while (i < data.length && data[i] === 0) i++;
|
||
let count = i - start;
|
||
while (count > 0) {
|
||
const chunk = Math.min(count, 65535);
|
||
out.push(0, chunk & 0xFF, (chunk >> 8) & 0xFF);
|
||
count -= chunk;
|
||
}
|
||
} else { out.push(data[i]); i++; }
|
||
}
|
||
return new Uint8Array(out);
|
||
}
|
||
|
||
function rleDecode(data) {
|
||
const out = [];
|
||
let i = 0;
|
||
while (i < data.length) {
|
||
if (data[i] === 0 && i + 2 < data.length) {
|
||
const count = data[i+1] | (data[i+2] << 8);
|
||
for (let j = 0; j < count; j++) out.push(0);
|
||
i += 3;
|
||
} else { out.push(data[i]); i++; }
|
||
}
|
||
return new Uint8Array(out);
|
||
}
|
||
|
||
// ─── Scene Generators ───
|
||
function generateWaves(t) {
|
||
const img = ctx.createImageData(W, H);
|
||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
||
const i = (y * W + x) * 4;
|
||
const v1 = Math.sin(x*0.03 + t*2) * Math.cos(y*0.02 + t) * 0.5 + 0.5;
|
||
const v2 = Math.sin((x+y)*0.02 - t*1.5) * 0.5 + 0.5;
|
||
img.data[i] = (v1 * 100 + 30)|0;
|
||
img.data[i+1] = (v2 * 130 + 80)|0;
|
||
img.data[i+2] = (v1 * v2 * 255)|0;
|
||
img.data[i+3] = 255;
|
||
}
|
||
return img;
|
||
}
|
||
|
||
function generatePlasma(t) {
|
||
const img = ctx.createImageData(W, H);
|
||
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
||
const i = (y * W + x) * 4;
|
||
const v = Math.sin(x*0.05+t) + Math.sin(y*0.05+t*0.7) + Math.sin((x+y)*0.03+t*1.3) + Math.sin(Math.sqrt(x*x+y*y)*0.04);
|
||
const c = (v + 4) / 8;
|
||
img.data[i] = (Math.sin(c*Math.PI*2)*127+128)|0;
|
||
img.data[i+1] = (Math.sin(c*Math.PI*2+2.094)*127+128)|0;
|
||
img.data[i+2] = (Math.sin(c*Math.PI*2+4.189)*127+128)|0;
|
||
img.data[i+3] = 255;
|
||
}
|
||
return img;
|
||
}
|
||
|
||
let rainDrops = Array.from({length:60}, () => ({x:Math.random()*W, y:Math.random()*H, s:1+Math.random()*3, l:3+Math.random()*8}));
|
||
function generateRain(t) {
|
||
const img = ctx.createImageData(W, H);
|
||
// dark bg
|
||
for (let i = 0; i < img.data.length; i += 4) { img.data[i]=8; img.data[i+1]=10; img.data[i+2]=20; img.data[i+3]=255; }
|
||
rainDrops.forEach(d => {
|
||
d.y += d.s; if (d.y > H) { d.y = 0; d.x = Math.random()*W; }
|
||
for (let j = 0; j < d.l; j++) {
|
||
const py = (d.y - j)|0;
|
||
if (py >= 0 && py < H) {
|
||
const idx = (py * W + (d.x|0)) * 4;
|
||
const alpha = 1 - j/d.l;
|
||
img.data[idx] = (100 * alpha)|0;
|
||
img.data[idx+1] = (180 * alpha)|0;
|
||
img.data[idx+2] = (255 * alpha)|0;
|
||
}
|
||
}
|
||
});
|
||
return img;
|
||
}
|
||
|
||
let matrixCols = Array.from({length:W/6|0}, () => ({y:Math.random()*H, speed:2+Math.random()*5}));
|
||
function generateMatrix(t) {
|
||
const img = ctx.createImageData(W, H);
|
||
for (let i = 0; i < img.data.length; i += 4) { img.data[i]=0; img.data[i+1]=5; img.data[i+2]=0; img.data[i+3]=255; }
|
||
matrixCols.forEach((col, ci) => {
|
||
col.y += col.speed; if (col.y > H + 40) col.y = -20;
|
||
for (let j = 0; j < 15; j++) {
|
||
const py = (col.y - j*8)|0;
|
||
if (py >= 0 && py < H) {
|
||
const x = ci * 6;
|
||
const brightness = j === 0 ? 255 : Math.max(40, 200 - j*15);
|
||
for (let dx = 0; dx < 5 && x+dx < W; dx++) {
|
||
for (let dy = 0; dy < 7 && py+dy < H; dy++) {
|
||
const idx = ((py+dy)*W+(x+dx))*4;
|
||
if (Math.random() > 0.3) img.data[idx+1] = brightness;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
return img;
|
||
}
|
||
|
||
const generators = [generateWaves, generatePlasma, generateRain, generateMatrix];
|
||
|
||
// ─── Protocol Message Logging ───
|
||
function logMessage(dir, type, seq, size, extra='') {
|
||
const log = document.getElementById('proto-log');
|
||
const name = FRAME_TYPES[type] || `0x${type.toString(16).padStart(2,'0')}`;
|
||
const dirClass = dir === 'OUT' ? 'proto-out' : 'proto-in';
|
||
const div = document.createElement('div');
|
||
div.className = 'proto-msg';
|
||
div.innerHTML = `<span class="proto-dir ${dirClass}">${dir}</span><span class="proto-type">${name}</span><span class="proto-seq">#${seq}</span>${extra?`<span style="color:var(--dim)">${extra}</span>`:''}<span class="proto-size">${size}B</span>`;
|
||
log.prepend(div);
|
||
while (log.children.length > 60) log.lastChild.remove();
|
||
msgCount++;
|
||
document.getElementById('msg-count').textContent = msgCount + ' msgs';
|
||
}
|
||
|
||
// ─── Hex Dump ───
|
||
function hexDump(header, payloadSize) {
|
||
const el = document.getElementById('hex-view');
|
||
const hdr = decodeHeader(header);
|
||
const bytes = Array.from(header).map(b => b.toString(16).padStart(2,'0'));
|
||
el.innerHTML =
|
||
`<span class="hex-offset">0000</span> <span class="hex-header">${bytes.slice(0,4).join(' ')}</span> <span class="hex-header">${bytes.slice(4,8).join(' ')}</span> <span class="hex-header">${bytes.slice(8,12).join(' ')}</span> <span class="hex-header">${bytes.slice(12,16).join(' ')}</span>\n` +
|
||
`<span style="color:var(--dim)"> type=<span class="hex-byte">0x${bytes[0]}</span> flags=<span class="hex-byte">0x${bytes[1]}</span> seq=<span class="hex-byte">${hdr.seq}</span> ts=<span class="hex-byte">${hdr.timestamp}</span></span>\n` +
|
||
`<span style="color:var(--dim)"> ${hdr.width}×${hdr.height} payload=<span class="hex-payload">${payloadSize} bytes</span></span>`;
|
||
|
||
// Live header display
|
||
document.getElementById('hdr-live').innerHTML = `Last: <span class="hdr-field hdr-type">0x${bytes[0]}</span> <span class="hdr-field hdr-flags">0x${bytes[1]}</span> <span class="hdr-field hdr-seq">${hdr.seq}</span> <span class="hdr-field hdr-ts">${hdr.timestamp}ms</span> <span class="hdr-field hdr-dim">${hdr.width}×${hdr.height}</span> <span class="hdr-field hdr-len">${payloadSize}B</span>`;
|
||
}
|
||
|
||
// ─── Update Signals ───
|
||
function updateSignals() {
|
||
if (!showSignals) return;
|
||
const el = document.getElementById('signal-list');
|
||
signals.time = (performance.now()/1000).toFixed(1);
|
||
signals.count = frameCount;
|
||
signals.fps = targetFPS;
|
||
signals.scene = scenes[currentScene].toLowerCase();
|
||
signals.quality = rttMs < 50 ? 'full' : rttMs < 150 ? 'reduced' : 'minimal';
|
||
el.innerHTML = Object.entries(signals).map(([k,v]) =>
|
||
`<div class="sig-row"><span class="sig-name">${k}</span><span class="sig-val">${v}</span></div>`
|
||
).join('');
|
||
document.getElementById('sig-count').textContent = Object.keys(signals).length + ' signals';
|
||
}
|
||
|
||
// ─── Compression Chart ───
|
||
function updateCompressionChart(ratio) {
|
||
compressionHistory.push(ratio);
|
||
if (compressionHistory.length > 40) compressionHistory.shift();
|
||
const el = document.getElementById('comp-chart');
|
||
el.innerHTML = compressionHistory.map(r => {
|
||
const h = Math.max(2, (1-r) * 60);
|
||
const c = r < 0.3 ? 'var(--green)' : r < 0.6 ? 'var(--yellow)' : 'var(--accent)';
|
||
return `<div class="comp-bar" style="height:${h}px;background:${c}" data-ratio="${((1-r)*100).toFixed(0)}%"></div>`;
|
||
}).join('');
|
||
const avg = compressionHistory.reduce((a,b)=>a+b,0) / compressionHistory.length;
|
||
document.getElementById('avg-ratio').textContent = `avg: ${((1-avg)*100).toFixed(0)}% saved`;
|
||
}
|
||
|
||
// ─── Quality Update ───
|
||
function updateQuality() {
|
||
const fill = document.getElementById('q-fill');
|
||
const el = document.getElementById('s-quality');
|
||
if (rttMs < 50) { fill.style.width = '100%'; fill.style.background = 'var(--green)'; el.textContent = 'Full'; el.style.color = 'var(--green)'; }
|
||
else if (rttMs < 150) { fill.style.width = '50%'; fill.style.background = 'var(--yellow)'; el.textContent = 'Med'; el.style.color = 'var(--yellow)'; }
|
||
else { fill.style.width = '15%'; fill.style.background = 'var(--red)'; el.textContent = 'Min'; el.style.color = 'var(--red)'; }
|
||
}
|
||
|
||
// ─── Main Loop ───
|
||
let t = 0;
|
||
function streamFrame() {
|
||
if (!running) return;
|
||
t += 0.016;
|
||
const timestamp = (performance.now())|0;
|
||
|
||
// Generate frame
|
||
const imageData = generators[currentScene](t);
|
||
const currentPixels = new Uint8Array(imageData.data.buffer);
|
||
|
||
let payloadBytes, frameType, flags, isKey = false;
|
||
|
||
if (!previousFrame || !useDelta || frameCount % 60 === 0) {
|
||
// Keyframe
|
||
payloadBytes = currentPixels;
|
||
frameType = 0x01; flags = FLAG_KEYFRAME;
|
||
isKey = true;
|
||
updateCompressionChart(1.0);
|
||
} else {
|
||
// Delta frame with RLE
|
||
const delta = computeDelta(currentPixels, previousFrame);
|
||
const compressed = rleEncode(delta);
|
||
const ratio = compressed.length / currentPixels.length;
|
||
payloadBytes = compressed;
|
||
frameType = 0x03; flags = FLAG_COMPRESSED;
|
||
updateCompressionChart(ratio);
|
||
}
|
||
|
||
// Encode header
|
||
const header = encodeHeader(frameType, flags, seq, timestamp, W, H, payloadBytes.length);
|
||
totalBytes += HEADER_SIZE + payloadBytes.length;
|
||
|
||
// Log
|
||
const extra = isKey ? 'KEY' : `${((1-payloadBytes.length/currentPixels.length)*100).toFixed(0)}% saved`;
|
||
logMessage('OUT', frameType, seq, HEADER_SIZE + payloadBytes.length, extra);
|
||
hexDump(header, payloadBytes.length);
|
||
|
||
// "Receive" — decode on receiver side
|
||
if (frameType === 0x03 && previousFrame) {
|
||
const decodedDelta = rleDecode(payloadBytes);
|
||
const reconstructed = applyDelta(previousFrame, decodedDelta);
|
||
const recImg = new ImageData(new Uint8ClampedArray(reconstructed.buffer), W, H);
|
||
ctx.putImageData(recImg, 0, 0);
|
||
previousFrame = reconstructed;
|
||
} else {
|
||
ctx.putImageData(imageData, 0, 0);
|
||
previousFrame = new Uint8Array(currentPixels);
|
||
}
|
||
|
||
// ACK back
|
||
if (frameCount % 5 === 0) {
|
||
const ackHeader = encodeHeader(0xFD, 0, seq, timestamp, 0, 0, 4);
|
||
logMessage('IN', 0xFD, seq, HEADER_SIZE + 4, `rtt=${rttMs}ms`);
|
||
}
|
||
|
||
// Signal sync
|
||
if (showSignals && frameCount % 30 === 0) {
|
||
const sigJson = JSON.stringify(signals);
|
||
logMessage('OUT', 0x30, seq, HEADER_SIZE + sigJson.length, `${Object.keys(signals).length} sigs`);
|
||
} else if (showSignals && frameCount % 10 === 0) {
|
||
logMessage('OUT', 0x31, seq, HEADER_SIZE + 20, 'diff');
|
||
}
|
||
|
||
// Ping
|
||
if (frameCount % 90 === 0) {
|
||
logMessage('OUT', 0xFE, seq, HEADER_SIZE, '♥');
|
||
}
|
||
|
||
seq = (seq + 1) & 0xFFFF;
|
||
frameCount++;
|
||
fpsCounter++;
|
||
|
||
// Update UI
|
||
document.getElementById('badge-frame').textContent = `${isKey?'K':'Δ'} #${frameCount}`;
|
||
document.getElementById('s-frames').textContent = frameCount;
|
||
document.getElementById('s-bytes').textContent = (totalBytes/1024).toFixed(0);
|
||
document.getElementById('s-rtt').textContent = rttMs + 'ms';
|
||
const savings = previousFrame && useDelta ? ((1-payloadBytes.length/(W*H*4))*100).toFixed(0) : '0';
|
||
document.getElementById('s-ratio').textContent = savings + '%';
|
||
updateSignals();
|
||
updateQuality();
|
||
}
|
||
|
||
// FPS counter
|
||
setInterval(() => {
|
||
const now = performance.now();
|
||
const fps = (fpsCounter / ((now - lastFpsTick) / 1000)).toFixed(0);
|
||
document.getElementById('s-fps').textContent = fps;
|
||
document.getElementById('badge-fps').textContent = fps + ' FPS';
|
||
fpsCounter = 0; lastFpsTick = now;
|
||
}, 1000);
|
||
|
||
// Stream loop
|
||
let streamTimer;
|
||
function startStream() {
|
||
streamTimer = setInterval(streamFrame, 1000 / targetFPS);
|
||
}
|
||
startStream();
|
||
|
||
// ─── Controls ───
|
||
function toggleStream() {
|
||
running = !running;
|
||
const btn = document.getElementById('btn-play');
|
||
btn.textContent = running ? '⏸ Pause' : '▶ Play';
|
||
btn.classList.toggle('active', running);
|
||
document.getElementById('badge-live').textContent = running ? '● LIVE' : '⏸ PAUSED';
|
||
document.getElementById('badge-live').style.color = running ? 'var(--red)' : 'var(--muted)';
|
||
document.getElementById('stream-status').textContent = running ? '● LIVE' : '⏸ PAUSED';
|
||
document.getElementById('stream-status').style.color = running ? 'var(--green)' : 'var(--muted)';
|
||
}
|
||
|
||
function sendKeyframe() { previousFrame = null; logMessage('IN', 0xF0, seq, HEADER_SIZE, 'forced'); }
|
||
|
||
function cycleFPS() {
|
||
const rates = [15, 30, 60];
|
||
const idx = (rates.indexOf(targetFPS) + 1) % rates.length;
|
||
targetFPS = rates[idx];
|
||
document.getElementById('fps-label').textContent = targetFPS + ' FPS';
|
||
clearInterval(streamTimer); startStream();
|
||
}
|
||
|
||
function cycleScene() {
|
||
currentScene = (currentScene + 1) % scenes.length;
|
||
document.getElementById('scene-label').textContent = scenes[currentScene];
|
||
previousFrame = null;
|
||
}
|
||
|
||
function toggleDelta() {
|
||
useDelta = !useDelta;
|
||
document.getElementById('delta-label').textContent = `Delta: ${useDelta?'ON':'OFF'}`;
|
||
}
|
||
|
||
function toggleSignals() {
|
||
showSignals = !showSignals;
|
||
document.getElementById('sig-label').textContent = `Signals: ${showSignals?'ON':'OFF'}`;
|
||
}
|
||
|
||
function simulateLatency() { rttMs = Math.min(300, rttMs + 50); updateQuality(); }
|
||
|
||
function resetStats() {
|
||
frameCount = 0; totalBytes = 0; seq = 0; msgCount = 0; rttMs = 12;
|
||
compressionHistory = []; previousFrame = null;
|
||
document.getElementById('proto-log').innerHTML = '';
|
||
document.getElementById('comp-chart').innerHTML = '';
|
||
}
|
||
|
||
// ─── Mouse tracking as input ───
|
||
const wrap = document.getElementById('canvas-wrap');
|
||
wrap.addEventListener('mousemove', e => {
|
||
const rect = wrap.getBoundingClientRect();
|
||
const x = ((e.clientX - rect.left) / rect.width * W)|0;
|
||
const y = ((e.clientY - rect.top) / rect.height * H)|0;
|
||
signals.mouse_x = x; signals.mouse_y = y;
|
||
document.getElementById('mouse-pos').textContent = `${x}, ${y}`;
|
||
if (running && frameCount % 3 === 0) logMessage('IN', 0x01, seq, HEADER_SIZE+5, `${x},${y}`);
|
||
});
|
||
|
||
wrap.addEventListener('click', e => {
|
||
const rect = wrap.getBoundingClientRect();
|
||
const x = ((e.clientX - rect.left) / rect.width * W)|0;
|
||
const y = ((e.clientY - rect.top) / rect.height * H)|0;
|
||
logMessage('IN', 0x02, seq, HEADER_SIZE+5, `click ${x},${y}`);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|