dreamstack/engine/demo/receiver.html

536 lines
17 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DreamStack — Pixel Stream Receiver</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0
}
:root {
--bg: #0a0a12;
--card: #12121e;
--surface: #1a1a2e;
--blue: #4f8fff;
--purple: #8b5cf6;
--green: #10b981;
--orange: #f59e0b;
--red: #ef4444;
--cyan: #06b6d4;
--text: #e2e8f0;
--dim: #64748b;
--border: #1e293b
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center
}
.header {
text-align: center;
padding: 1.5rem 1rem 1rem;
position: relative;
width: 100%
}
.header::after {
content: '';
position: absolute;
bottom: 0;
left: 10%;
width: 80%;
height: 1px;
background: linear-gradient(90deg, transparent, var(--purple), var(--blue), transparent)
}
.header h1 {
font-size: 1.6rem;
font-weight: 700;
background: linear-gradient(135deg, var(--purple), var(--blue));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent
}
.header .sub {
font-family: 'JetBrains Mono', monospace;
font-size: .7rem;
color: var(--purple);
margin-top: .2rem;
letter-spacing: .05em
}
.status-bar {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: .8rem;
font-family: 'JetBrains Mono', monospace;
font-size: .7rem
}
.status-item {
display: flex;
align-items: center;
gap: .4rem
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%
}
.status-dot.on {
background: var(--green);
box-shadow: 0 0 8px var(--green)
}
.status-dot.off {
background: var(--red)
}
.hint {
text-align: center;
font-size: .65rem;
color: var(--dim);
padding: .3rem;
font-style: italic
}
.hint em {
color: var(--orange);
font-style: normal
}
.content {
display: flex;
gap: 1.5rem;
padding: 1rem 1.5rem;
max-width: 900px;
width: 100%;
align-items: flex-start
}
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
flex: 1;
box-shadow: 0 0 20px rgba(139, 92, 246, 0.2)
}
.panel-label {
display: flex;
align-items: center;
gap: .5rem;
padding: .6rem 1rem;
font-size: .7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
border-bottom: 1px solid var(--border);
color: var(--purple)
}
.panel-label .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--purple);
animation: pulse 2s infinite
}
@keyframes pulse {
0%,
100% {
opacity: 1
}
50% {
opacity: .3
}
}
canvas {
display: block;
width: 100%;
image-rendering: auto;
background: #08080f;
cursor: crosshair
}
.stats {
display: flex;
gap: 1rem;
padding: .5rem 1rem;
font-family: 'JetBrains Mono', monospace;
font-size: .55rem;
color: var(--dim);
flex-wrap: wrap;
border-top: 1px solid var(--border)
}
.stat {
display: flex;
gap: .3rem
}
.stat-v {
color: var(--green)
}
.stat-v.rtt {
color: var(--orange)
}
.stat-v.compress {
color: var(--cyan)
}
.side-panel {
display: flex;
flex-direction: column;
gap: .75rem;
min-width: 200px;
max-width: 220px;
padding-top: 1rem
}
.info-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: .5rem .75rem;
font-family: 'JetBrains Mono', monospace;
font-size: .55rem
}
.info-card .title {
color: var(--cyan);
font-weight: 600;
text-transform: uppercase;
letter-spacing: .1em;
margin-bottom: .3rem;
font-size: .6rem
}
.info-card .val {
color: var(--orange);
word-break: break-all;
line-height: 1.4
}
.info-card.rtt-card {
border-color: var(--orange)
}
.info-card.rtt-card .title {
color: var(--orange)
}
.info-card.compress-card {
border-color: var(--cyan)
}
.info-card.compress-card .title {
color: var(--cyan)
}
.rtt-big {
font-size: 1.4rem;
font-weight: 700;
color: var(--orange);
text-align: center;
padding: .3rem 0
}
.compress-big {
font-size: 1.4rem;
font-weight: 700;
color: var(--cyan);
text-align: center;
padding: .3rem 0
}
.rtt-label {
font-size: .5rem;
color: var(--dim);
text-align: center
}
.waiting {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 360px;
gap: 1rem;
color: var(--dim)
}
.waiting .spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--purple);
border-radius: 50%;
animation: spin 1s linear infinite
}
@keyframes spin {
to {
transform: rotate(360deg)
}
}
</style>
</head>
<body>
<div class="header">
<h1>DreamStack — Receiver</h1>
<div class="sub">ds-stream delta receiver · interactive</div>
</div>
<div class="status-bar">
<div class="status-item"><span class="status-dot off" id="wsDot"></span><span id="wsStatus"
style="color:var(--red)">Disconnected</span></div>
<div class="status-item"><span style="color:var(--dim)">Relay:</span> <span
style="color:var(--orange)">ws://localhost:9800/stream</span></div>
</div>
<div class="hint">👆 <em>Click on the stream</em> to interact — forces sent via relay, round-trip latency measured
</div>
<div class="content">
<div class="panel">
<div class="panel-label"><span class="dot"></span> Pixel Stream · Click to Interact</div>
<div id="waitingMsg" class="waiting">
<div class="spinner"></div>
<div>Waiting for source stream...</div>
</div>
<canvas id="receiver" width="480" height="360" style="display:none"></canvas>
<div class="stats">
<div class="stat">Frames <span class="stat-v" id="frames">0</span></div>
<div class="stat">Bytes <span class="stat-v" id="bytes">0B</span></div>
<div class="stat">FPS <span class="stat-v" id="fps">0</span></div>
<div class="stat">Decode <span class="stat-v" id="latency"></span></div>
<div class="stat">RTT <span class="stat-v rtt" id="rttStat"></span></div>
<div class="stat">Saved <span class="stat-v compress" id="savedStat"></span></div>
</div>
</div>
<div class="side-panel">
<div class="info-card rtt-card">
<div class="title">⏱ Round-Trip Latency</div>
<div class="rtt-big" id="rttBig"></div>
<div class="rtt-label">click → source → render → back</div>
<div class="val" style="margin-top:.3rem;font-size:.5rem" id="rttHistory">Click to measure</div>
</div>
<div class="info-card compress-card">
<div class="title">📦 Compression</div>
<div class="compress-big" id="compressBig"></div>
<div class="rtt-label">delta + RLE vs raw RGBA</div>
<div class="val" style="margin-top:.3rem;font-size:.5rem" id="compressDetail"></div>
</div>
<div class="info-card">
<div class="title">Frame Type</div>
<div class="val" id="frameType"></div>
</div>
<div class="info-card">
<div class="title">Interactions</div>
<div class="val" id="clickCount">0 clicks sent</div>
</div>
</div>
</div>
<script>
const HEADER_SIZE = 16;
const FRAME_KEYFRAME = 0x11;
const FRAME_DELTA = 0x12;
const SIGNAL_CLICK = 0x30;
const SIGNAL_PONG = 0x31;
const canvas = document.getElementById('receiver');
const ctx = canvas.getContext('2d');
let ws = null;
let totalFrames = 0, totalBytes = 0, clicksSent = 0;
let fpsCnt = 0, displayFps = 0, lastFpsTime = performance.now();
const rttHistory = [];
// Frame buffer for delta decoding
let frameBuffer = null;
const RAW_SIZE = 480 * 360 * 4; // 691200 bytes
function decodeHeader(buffer) {
const frame = new Uint8Array(buffer);
const view = new DataView(buffer);
return {
type: frame[0],
seq: view.getUint16(2, true),
width: view.getUint16(8, true),
height: view.getUint16(10, true),
payloadLength: view.getUint32(12, true),
payload: new Uint8Array(buffer, HEADER_SIZE),
};
}
// Decode RLE delta and apply XOR to frame buffer
function applyDeltaRLE(payload, buffer, totalBytes) {
let pi = 0; // payload index
let bi = 0; // buffer index
while (pi < payload.length && bi < totalBytes) {
const runLen = (payload[pi] << 8) | payload[pi + 1];
const dr = payload[pi + 2];
const dg = payload[pi + 3];
const db = payload[pi + 4];
const da = payload[pi + 5];
pi += 6;
for (let r = 0; r < runLen && bi < totalBytes; r++) {
buffer[bi] ^= dr;
buffer[bi + 1] ^= dg;
buffer[bi + 2] ^= db;
buffer[bi + 3] ^= da;
bi += 4;
}
}
}
function fmtB(b) { if (b < 1024) return b + 'B'; if (b < 1048576) return (b / 1024).toFixed(1) + 'KB'; return (b / 1048576).toFixed(1) + 'MB'; }
function renderBuffer() {
const imgData = ctx.createImageData(480, 360);
imgData.data.set(frameBuffer);
ctx.putImageData(imgData, 0, 0);
}
// ─── Click → Signal ───
canvas.addEventListener('click', (e) => {
if (!ws || ws.readyState !== 1) return;
const rect = canvas.getBoundingClientRect();
const clickX = (e.clientX - rect.left) * (480 / rect.width);
const clickY = (e.clientY - rect.top) * (360 / rect.height);
const signal = new ArrayBuffer(20);
const sv = new DataView(signal);
new Uint8Array(signal)[0] = SIGNAL_CLICK;
sv.setFloat32(4, clickX, true);
sv.setFloat32(8, clickY, true);
sv.setFloat64(12, performance.now(), true);
ws.send(signal);
clicksSent++;
document.getElementById('clickCount').textContent = `${clicksSent} clicks sent`;
});
// ─── WebSocket ───
function connectWS() {
ws = new WebSocket('ws://localhost:9800/stream');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
document.getElementById('wsDot').className = 'status-dot on';
document.getElementById('wsStatus').textContent = 'Connected';
document.getElementById('wsStatus').style.color = 'var(--green)';
};
ws.onmessage = (evt) => {
const data = new Uint8Array(evt.data);
// Handle pong (RTT response)
if (data[0] === SIGNAL_PONG && evt.data.byteLength >= 20) {
const view = new DataView(evt.data);
const originalTs = view.getFloat64(12, true);
const rtt = performance.now() - originalTs;
rttHistory.push(rtt);
if (rttHistory.length > 20) rttHistory.shift();
const avg = rttHistory.reduce((a, b) => a + b, 0) / rttHistory.length;
document.getElementById('rttBig').textContent = rtt.toFixed(1) + 'ms';
document.getElementById('rttStat').textContent = rtt.toFixed(1) + 'ms';
document.getElementById('rttHistory').textContent =
`avg: ${avg.toFixed(1)}ms · min: ${Math.min(...rttHistory).toFixed(1)}ms · max: ${Math.max(...rttHistory).toFixed(1)}ms (n=${rttHistory.length})`;
return;
}
// Handle pixel frames
if (data[0] !== FRAME_KEYFRAME && data[0] !== FRAME_DELTA) return;
const t0 = performance.now();
const hdr = decodeHeader(evt.data);
const isKey = hdr.type === FRAME_KEYFRAME;
if (isKey) {
// Keyframe: replace entire buffer
frameBuffer = new Uint8Array(RAW_SIZE);
frameBuffer.set(hdr.payload.subarray(0, RAW_SIZE));
} else {
// Delta: apply RLE-decoded XOR to existing buffer
if (!frameBuffer) return; // can't apply delta without a keyframe first
applyDeltaRLE(hdr.payload, frameBuffer, RAW_SIZE);
}
document.getElementById('waitingMsg').style.display = 'none';
canvas.style.display = 'block';
renderBuffer();
totalFrames++;
totalBytes += evt.data.byteLength;
fpsCnt++;
const now = performance.now();
if (now - lastFpsTime >= 1000) {
displayFps = fpsCnt;
fpsCnt = 0;
lastFpsTime = now;
}
const decodeMs = (now - t0).toFixed(1);
const wireSize = evt.data.byteLength;
const saved = ((1 - wireSize / (RAW_SIZE + HEADER_SIZE)) * 100).toFixed(0);
document.getElementById('frames').textContent = totalFrames;
document.getElementById('bytes').textContent = fmtB(totalBytes);
document.getElementById('fps').textContent = displayFps;
document.getElementById('latency').textContent = decodeMs + 'ms';
document.getElementById('savedStat').textContent = saved + '%';
document.getElementById('frameType').textContent = isKey
? `KEY (0x11) · ${fmtB(wireSize)} raw`
: `Δ (0x12) · ${fmtB(wireSize)} compressed`;
document.getElementById('compressBig').textContent = isKey ? '0%' : saved + '%';
document.getElementById('compressDetail').textContent =
`${fmtB(wireSize)} wire vs ${fmtB(RAW_SIZE)} raw`;
};
ws.onclose = () => {
document.getElementById('wsDot').className = 'status-dot off';
document.getElementById('wsStatus').textContent = 'Disconnected';
document.getElementById('wsStatus').style.color = 'var(--red)';
setTimeout(connectWS, 2000);
};
ws.onerror = () => ws.close();
}
connectWS();
</script>
</body>
</html>