- Source (index.html): physics sim → pixel capture → delta XOR + RLE encode → WebSocket - Receiver (receiver.html): WebSocket → decode delta/keyframe → render + click interaction + RTT - Relay (relay.js): bidirectional WebSocket relay (pixels ↓ signals ↑) - 97% compression (18KB/frame vs 675KB raw RGBA) - Interactive: click on receiver → impulse force on source → round-trip latency measured - Frame types: 0x11 keyframe (every 60 frames), 0x12 delta+RLE - No buffer bloat: drops frames when pipe is busy
536 lines
No EOL
17 KiB
HTML
536 lines
No EOL
17 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 — 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> |