dreamstack/examples/stream-source.html
enzotar 968d62d0bb feat(demos): sync protocol with Rust codec, add touch/gamepad support
- RLE format: 2-byte LE count (matches Rust rle_encode/rle_decode)
- Delta frames: FLAG_COMPRESSED flag set correctly
- Signal sync: FLAG_KEYFRAME flag + periodic sync every ~5s for late-join
- Source: handle touch, gamepad axis/button, resize inputs from receiver
- Receiver: touch event capture for mobile, ping frame handling
- Protocol constants: added FRAME_PING, INPUT_TOUCH, INPUT_GAMEPAD_*
2026-02-25 11:06:42 -08:00

817 lines
No EOL
38 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 Source</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>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background: #0a0a0f;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
background: linear-gradient(135deg, #6366f1, #8b5cf6, #ec4899);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.3rem;
}
.subtitle {
color: rgba(255, 255, 255, 0.25);
font-size: 0.75rem;
margin-bottom: 1rem;
}
.layout {
display: flex;
gap: 1.5rem;
align-items: flex-start;
flex-wrap: wrap;
justify-content: center;
}
.panel {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 1rem;
}
.panel h2 {
font-size: 0.8rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.8rem;
}
canvas {
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
cursor: pointer;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.4rem;
min-width: 320px;
}
.stat {
background: rgba(99, 102, 241, 0.06);
border: 1px solid rgba(99, 102, 241, 0.1);
border-radius: 10px;
padding: 0.5rem 0.7rem;
}
.stat-label {
font-size: 0.55rem;
color: rgba(255, 255, 255, 0.3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.95rem;
font-weight: 600;
color: #c4b5fd;
margin-top: 1px;
}
.connection {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 500;
margin-bottom: 1rem;
}
.connection.disconnected {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.connection.connected {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #86efac;
}
.connection .dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
.connection.disconnected .dot {
background: #ef4444;
}
.connection.connected .dot {
background: #22c55e;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.controls {
display: flex;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
.controls button {
padding: 0.35rem 0.8rem;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(139, 92, 246, 0.12));
color: #c4b5fd;
cursor: pointer;
font-size: 0.7rem;
font-weight: 500;
transition: all 0.15s;
border: 1px solid rgba(139, 92, 246, 0.1);
}
.controls button:hover {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.25), rgba(139, 92, 246, 0.25));
transform: translateY(-1px);
}
.controls button.active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.4), rgba(139, 92, 246, 0.4));
border-color: rgba(139, 92, 246, 0.4);
}
.mode-row {
display: flex;
gap: 0.3rem;
margin-top: 0.5rem;
}
.mode-btn {
padding: 0.3rem 0.7rem;
border: none;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.3);
cursor: pointer;
font-size: 0.65rem;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all 0.15s;
}
.mode-btn.active {
background: rgba(139, 92, 246, 0.2);
color: #c4b5fd;
border-color: rgba(139, 92, 246, 0.3);
}
.bitstream-vis {
display: flex;
gap: 2px;
height: 25px;
align-items: flex-end;
margin-top: 0.4rem;
}
.bitstream-bar {
width: 3px;
background: linear-gradient(to top, #6366f1, #8b5cf6);
border-radius: 1px;
min-height: 2px;
transition: height 0.1s ease;
}
.powered {
margin-top: 1rem;
font-size: 0.6rem;
color: rgba(255, 255, 255, 0.08);
}
.powered span {
color: rgba(139, 92, 246, 0.25);
}
</style>
</head>
<body>
<h1>⚡ Bitstream Source</h1>
<p class="subtitle">Renders the UI. Streams pixels/signals/audio. Receives inputs.</p>
<div class="connection disconnected" id="connStatus">
<div class="dot"></div>
<span id="connText">Connecting to relay...</span>
</div>
<div class="layout">
<div class="panel">
<h2>Rendered Scene</h2>
<canvas id="scene" width="600" height="400"></canvas>
<div class="controls" id="controls"></div>
<div class="mode-row">
<button class="mode-btn active" id="modePixel" onclick="setMode('pixel')">📺 Pixels</button>
<button class="mode-btn" id="modeDelta" onclick="setMode('delta')">Δ Delta</button>
<button class="mode-btn" id="modeSignal" onclick="setMode('signal')">📡 Signals</button>
<button class="mode-btn" id="modeNeural" onclick="setMode('neural')">🧠 Neural</button>
<button class="mode-btn" id="modeAudio" onclick="toggleAudio()">🔇 Audio</button>
</div>
</div>
<div class="panel">
<h2>Stream Stats</h2>
<div class="stats">
<div class="stat">
<div class="stat-label">FPS</div>
<div class="stat-value" id="statFps">0</div>
</div>
<div class="stat">
<div class="stat-label">Mode</div>
<div class="stat-value" id="statMode">pixel</div>
</div>
<div class="stat">
<div class="stat-label">Receivers</div>
<div class="stat-value" id="statReceivers">0</div>
</div>
<div class="stat">
<div class="stat-label">Frame Size</div>
<div class="stat-value" id="statFrameSize">0</div>
</div>
<div class="stat">
<div class="stat-label">Bandwidth</div>
<div class="stat-value" id="statBandwidth">0</div>
</div>
<div class="stat">
<div class="stat-label">Delta Ratio</div>
<div class="stat-value" id="statDelta"></div>
</div>
<div class="stat">
<div class="stat-label">Frames</div>
<div class="stat-value" id="statFrames">0</div>
</div>
<div class="stat">
<div class="stat-label">Inputs</div>
<div class="stat-value" id="statInputs">0</div>
</div>
<div class="stat">
<div class="stat-label">Audio</div>
<div class="stat-value" id="statAudio">off</div>
</div>
</div>
<div class="bitstream-vis" id="bitstreamVis"></div>
</div>
</div>
<div class="powered">Built with <span>DreamStack</span> — universal bitstream</div>
<script>
// ════════════════════════════════════════════════════════
// DreamStack Runtime (signals + springs)
// ════════════════════════════════════════════════════════
const DS = (() => {
let currentEffect = null, batchDepth = 0, pendingEffects = new Set();
class Signal {
constructor(val) { this._value = val; this._subs = new Set(); }
get value() { if (currentEffect) this._subs.add(currentEffect); return this._value; }
set value(v) {
if (this._value === v) return; this._value = v;
if (batchDepth > 0) for (const s of this._subs) pendingEffects.add(s);
else for (const s of [...this._subs]) s._run();
}
}
class Effect {
constructor(fn) { this._fn = fn; this._disposed = false; }
_run() { if (this._disposed) return; const prev = currentEffect; currentEffect = this; try { this._fn(); } finally { currentEffect = prev; } }
dispose() { this._disposed = true; }
}
const signal = v => new Signal(v);
const effect = fn => { const e = new Effect(fn); e._run(); return e; };
const batch = fn => { batchDepth++; try { fn(); } finally { batchDepth--; if (batchDepth === 0) { const effs = [...pendingEffects]; pendingEffects.clear(); for (const e of effs) e._run(); } } };
const _activeSprings = new Set();
let _rafId = null, _lastTime = 0;
class Spring {
constructor({ value = 0, target, stiffness = 170, damping = 26, mass = 1 } = {}) {
this._signal = new Signal(value); this._velocity = 0;
this._target = target !== undefined ? target : value;
this.stiffness = stiffness; this.damping = damping; this.mass = mass; this._settled = true;
}
get value() { return this._signal.value; }
set value(v) { this.target = v; }
get target() { return this._target; }
set target(t) { this._target = t; this._settled = false; _activeSprings.add(this); _startLoop(); }
set(v) { this._signal.value = v; this._target = v; this._velocity = 0; this._settled = true; _activeSprings.delete(this); }
_step(dt) {
const pos = this._signal._value, vel = this._velocity;
const k = this.stiffness, d = this.damping, m = this.mass;
const a = (p, v) => (-k * (p - this._target) - d * v) / m;
const k1v = a(pos, vel), k1p = vel;
const k2v = a(pos + k1p * dt / 2, vel + k1v * dt / 2), k2p = vel + k1v * dt / 2;
const k3v = a(pos + k2p * dt / 2, vel + k2v * dt / 2), k3p = vel + k2v * dt / 2;
const k4v = a(pos + k3p * dt, vel + k3v * dt), k4p = vel + k3v * dt;
this._velocity = vel + (dt / 6) * (k1v + 2 * k2v + 2 * k3v + k4v);
this._signal.value = pos + (dt / 6) * (k1p + 2 * k2p + 2 * k3p + k4p);
if (Math.abs(this._velocity) < 0.01 && Math.abs(this._signal._value - this._target) < 0.01) {
this._signal.value = this._target; this._velocity = 0; this._settled = true; _activeSprings.delete(this);
}
}
}
function _startLoop() { if (_rafId !== null) return; _lastTime = performance.now(); _rafId = requestAnimationFrame(_loop); }
function _loop(now) {
const dt = Math.min((now - _lastTime) / 1000, 0.064); _lastTime = now;
batch(() => { for (const s of _activeSprings) { const steps = Math.ceil(dt / (1 / 120)); const subDt = dt / steps; for (let i = 0; i < steps; i++) s._step(subDt); } });
if (_activeSprings.size > 0) _rafId = requestAnimationFrame(_loop); else _rafId = null;
}
function spring(opts) { return new Spring(typeof opts === 'object' ? opts : { value: opts, target: opts }); }
return { signal, effect, batch, spring, Signal, Spring };
})();
// ════════════════════════════════════════════════════════
// Protocol Constants
// ════════════════════════════════════════════════════════
const HEADER_SIZE = 16;
const FRAME_PIXELS = 0x01, FRAME_DELTA = 0x03, FRAME_AUDIO = 0x10;
const FRAME_SIGNAL_SYNC = 0x30, FRAME_SIGNAL_DIFF = 0x31, FRAME_NEURAL = 0x40;
const FRAME_PING = 0xFE, FRAME_END = 0xFF;
const INPUT_POINTER = 0x01, INPUT_PTR_DOWN = 0x02, INPUT_PTR_UP = 0x03;
const INPUT_KEY_DOWN = 0x10, INPUT_KEY_UP = 0x11;
const INPUT_TOUCH = 0x20, INPUT_TOUCH_END = 0x21;
const INPUT_GAMEPAD_AXIS = 0x30, INPUT_GAMEPAD_BUTTON = 0x31;
const INPUT_SCROLL = 0x50, INPUT_RESIZE = 0x60;
const FLAG_INPUT = 0x01, FLAG_KEYFRAME = 0x02, FLAG_COMPRESSED = 0x04;
function encodeHeader(type, flags, seq, ts, w, h, len) {
const b = new ArrayBuffer(HEADER_SIZE), v = new DataView(b);
v.setUint8(0, type); v.setUint8(1, flags); v.setUint16(2, seq, true); v.setUint32(4, ts, true);
v.setUint16(8, w, true); v.setUint16(10, h, true); v.setUint32(12, len, true);
return new Uint8Array(b);
}
function decodeHeader(buf) {
const v = new DataView(buf.buffer || buf, buf.byteOffset || 0);
return {
type: v.getUint8(0), flags: v.getUint8(1), seq: v.getUint16(2, true), timestamp: v.getUint32(4, true),
width: v.getUint16(8, true), height: v.getUint16(10, true), length: v.getUint32(12, true)
};
}
// ════════════════════════════════════════════════════════
// Scene Setup
// ════════════════════════════════════════════════════════
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const W = 600, H = 400;
const ballX = DS.spring({ value: 300, stiffness: 170, damping: 26 });
const ballY = DS.spring({ value: 200, stiffness: 170, damping: 26 });
const ballR = DS.spring({ value: 25, stiffness: 300, damping: 20 });
const targetX = DS.signal(300), targetY = DS.signal(200);
function renderScene() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.lineWidth = 1;
for (let x = 0; x < W; x += 40) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); }
for (let y = 0; y < H; y += 40) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }
const bx = ballX.value, by = ballY.value, br = ballR.value, tx = targetX.value, ty = targetY.value;
ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(tx, ty);
ctx.strokeStyle = 'rgba(139,92,246,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]);
ctx.strokeStyle = 'rgba(99,102,241,0.08)'; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(tx, 0); ctx.lineTo(tx, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, ty); ctx.lineTo(W, ty); ctx.stroke(); ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(tx, ty, 6, 0, Math.PI * 2); ctx.fillStyle = 'rgba(99,102,241,0.3)'; ctx.fill();
ctx.beginPath(); ctx.arc(bx, by, br + 16, 0, Math.PI * 2); ctx.fillStyle = 'rgba(139,92,246,0.03)'; ctx.fill();
ctx.beginPath(); ctx.arc(bx, by, br + 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(139,92,246,0.08)'; ctx.fill();
ctx.beginPath(); ctx.arc(bx, by, br, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(bx - br * 0.3, by - br * 0.3, br * 0.1, bx, by, br);
grad.addColorStop(0, '#c4b5fd'); grad.addColorStop(1, '#8b5cf6');
ctx.fillStyle = grad; ctx.shadowColor = '#8b5cf6'; ctx.shadowBlur = 25; ctx.fill(); ctx.shadowBlur = 0;
ctx.font = '10px Inter'; ctx.fillStyle = 'rgba(255,255,255,0.06)';
ctx.fillText(`DreamStack — ${streamMode} mode`, 10, H - 10);
}
DS.effect(renderScene);
// ── Feature 5: Neural Renderer ──
// Generates pixels directly from signal state — no canvas primitives
// This is what a trained model would do: signal_state → framebuffer
function neuralRender() {
const bx = ballX._signal._value, by = ballY._signal._value;
const br = ballR._signal._value;
const imgData = ctx.createImageData(W, H);
const d = imgData.data;
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
const i = (py * W + px) * 4;
// Grid (procedural)
const onGrid = (px % 40 === 0 || py % 40 === 0) ? 8 : 0;
// Ball glow (distance field — a "learned" SDF)
const dx = px - bx, dy = py - by;
const dist = Math.sqrt(dx * dx + dy * dy);
const glow = Math.max(0, 1 - dist / (br + 30)) * 0.15;
const innerGlow = Math.max(0, 1 - dist / (br + 10)) * 0.3;
const solid = dist < br ? 1 : 0;
// "Neural" color mixing
const r = Math.min(255, onGrid + glow * 60 + innerGlow * 80 + solid * 139);
const g = Math.min(255, onGrid + glow * 30 + innerGlow * 40 + solid * 92);
const b2 = Math.min(255, onGrid + glow * 120 + innerGlow * 160 + solid * 246);
d[i] = r; d[i + 1] = g; d[i + 2] = b2; d[i + 3] = 255;
}
}
ctx.putImageData(imgData, 0, 0);
ctx.font = '10px Inter'; ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillText('🧠 Neural Renderer (procedural SDF)', 10, H - 10);
return imgData;
}
// ── Interaction ──
let dragging = false;
function handlePointer(x, y, type) {
if (type === 'down') {
const bx = ballX._signal._value, by = ballY._signal._value;
if (Math.hypot(x - bx, y - by) < 40) { dragging = true; canvas.style.cursor = 'grabbing'; }
else { targetX.value = x; targetY.value = y; ballX.value = x; ballY.value = y; }
} else if (type === 'move' && dragging) {
ballX.set(Math.max(20, Math.min(W - 20, x)));
ballY.set(Math.max(20, Math.min(H - 20, y)));
targetX.value = x; targetY.value = y;
} else if (type === 'up' && dragging) {
dragging = false; canvas.style.cursor = 'pointer';
ballX.value = 300; ballY.value = 200; targetX.value = 300; targetY.value = 200;
}
}
canvas.addEventListener('mousedown', e => { const r = canvas.getBoundingClientRect(); handlePointer(e.clientX - r.left, e.clientY - r.top, 'down'); });
window.addEventListener('mousemove', e => { if (!dragging) return; const r = canvas.getBoundingClientRect(); handlePointer(e.clientX - r.left, e.clientY - r.top, 'move'); });
window.addEventListener('mouseup', () => handlePointer(0, 0, 'up'));
// Preset buttons
const presets = [
{ label: '↖ TL', x: 60, y: 60 }, { label: '↗ TR', x: 540, y: 60 },
{ label: '⊙ Center', x: 300, y: 200 }, { label: '↙ BL', x: 60, y: 340 },
{ label: '↘ BR', x: 540, y: 340 },
{ label: '🎾 Bounce', action: 'bounce' }, { label: '💥 Pulse', action: 'pulse' },
];
const ctrlEl = document.getElementById('controls');
presets.forEach(p => {
const btn = document.createElement('button');
btn.textContent = p.label;
btn.addEventListener('click', () => {
if (p.action === 'bounce') {
const pos = [[80, 80], [520, 80], [520, 320], [80, 320]]; let i = 0;
const iv = setInterval(() => { const [x, y] = pos[i % pos.length]; targetX.value = x; targetY.value = y; ballX.value = x; ballY.value = y; if (++i >= 8) clearInterval(iv); }, 350);
} else if (p.action === 'pulse') { ballR.value = 70; setTimeout(() => ballR.value = 25, 250); }
else { targetX.value = p.x; targetY.value = p.y; ballX.value = p.x; ballY.value = p.y; }
});
ctrlEl.appendChild(btn);
});
// ════════════════════════════════════════════════════════
// Feature 4: Audio Synthesis
// ════════════════════════════════════════════════════════
let audioCtx = null, audioEnabled = false;
const AUDIO_SAMPLE_RATE = 22050, AUDIO_CHUNK_SIZE = 1024;
function toggleAudio() {
audioEnabled = !audioEnabled;
const btn = document.getElementById('modeAudio');
if (audioEnabled) {
if (!audioCtx) audioCtx = new AudioContext({ sampleRate: AUDIO_SAMPLE_RATE });
btn.textContent = '🔊 Audio'; btn.classList.add('active');
} else {
btn.textContent = '🔇 Audio'; btn.classList.remove('active');
}
}
function synthesizeAudio() {
if (!audioEnabled || !audioCtx) return null;
// Synthesize from spring state — velocity drives pitch, distance drives volume
const vel = Math.sqrt(ballX._velocity * ballX._velocity + ballY._velocity * ballY._velocity);
const dist = Math.hypot(ballX._signal._value - targetX._value, ballY._signal._value - targetY._value);
const freq = 220 + vel * 2; // base frequency + velocity
const amp = Math.min(0.3, dist / 500); // volume from distance
const samples = new Float32Array(AUDIO_CHUNK_SIZE);
const t0 = performance.now() / 1000;
for (let i = 0; i < AUDIO_CHUNK_SIZE; i++) {
const t = t0 + i / AUDIO_SAMPLE_RATE;
samples[i] = amp * Math.sin(2 * Math.PI * freq * t) * Math.exp(-vel * 0.001);
}
return samples;
}
function encodeAudioFrame(samples, seq, ts) {
const payload = new Uint8Array(samples.buffer);
const header = encodeHeader(FRAME_AUDIO, 0, seq, ts, 1, AUDIO_SAMPLE_RATE / 100, payload.length);
const msg = new Uint8Array(HEADER_SIZE + payload.length);
msg.set(header, 0); msg.set(payload, HEADER_SIZE);
return msg;
}
// ════════════════════════════════════════════════════════
// Streaming Engine
// ════════════════════════════════════════════════════════
let ws = null, seq = 0, streamStart = performance.now();
let framesSent = 0, bytesSent = 0, inputsRecv = 0;
let lastSecBytes = 0, lastSecFrames = 0, lastSecTime = performance.now();
let prevFrame = null; // for delta compression
let lastDeltaRatio = 0;
let streamMode = 'pixel'; // pixel | delta | signal | neural
let prevSignalState = null;
let receiverCount = 0;
// Bitstream viz
const visEl = document.getElementById('bitstreamVis');
const visBars = [];
for (let i = 0; i < 60; i++) {
const bar = document.createElement('div'); bar.className = 'bitstream-bar'; bar.style.height = '2px';
visEl.appendChild(bar); visBars.push(bar);
}
let visIdx = 0;
function setMode(mode) {
streamMode = mode;
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
const id = 'mode' + mode.charAt(0).toUpperCase() + mode.slice(1);
const el = document.getElementById(id);
if (el) el.classList.add('active');
prevFrame = null; prevSignalState = null; // reset state on mode change
}
function connect() {
ws = new WebSocket('ws://localhost:9100');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
document.getElementById('connStatus').className = 'connection connected';
document.getElementById('connText').textContent = 'Connected — streaming';
streamStart = performance.now(); startStreaming();
};
ws.onmessage = e => {
if (e.data instanceof ArrayBuffer && e.data.byteLength >= HEADER_SIZE) {
const header = decodeHeader(new Uint8Array(e.data));
if (header.flags & FLAG_INPUT) { inputsRecv++; handleRemoteInput(header, new Uint8Array(e.data, HEADER_SIZE)); }
}
};
ws.onclose = () => {
document.getElementById('connStatus').className = 'connection disconnected';
document.getElementById('connText').textContent = 'Disconnected — reconnecting...';
stopStreaming(); setTimeout(connect, 2000);
};
ws.onerror = () => {
document.getElementById('connStatus').className = 'connection disconnected';
document.getElementById('connText').textContent = 'Relay not found — cargo run -p ds-stream';
};
}
// ── Feature 2: Full Bidirectional Input ──
function handleRemoteInput(header, payload) {
const view = new DataView(payload.buffer, payload.byteOffset);
switch (header.type) {
case INPUT_PTR_DOWN:
if (payload.length >= 5) handlePointer(view.getUint16(0, true), view.getUint16(2, true), 'down');
break;
case INPUT_POINTER:
if (payload.length >= 5) handlePointer(view.getUint16(0, true), view.getUint16(2, true), 'move');
break;
case INPUT_PTR_UP:
handlePointer(0, 0, 'up');
break;
case INPUT_KEY_DOWN:
if (payload.length >= 3) {
const keycode = view.getUint16(0, true);
// Arrow keys drive ball
if (keycode === 37) { ballX.value = ballX._signal._value - 50; targetX.value = ballX._target; }
if (keycode === 39) { ballX.value = ballX._signal._value + 50; targetX.value = ballX._target; }
if (keycode === 38) { ballY.value = ballY._signal._value - 50; targetY.value = ballY._target; }
if (keycode === 40) { ballY.value = ballY._signal._value + 50; targetY.value = ballY._target; }
if (keycode === 32) { ballR.value = 70; setTimeout(() => ballR.value = 25, 250); } // space = pulse
}
break;
case INPUT_SCROLL:
if (payload.length >= 4) {
const dy = view.getInt16(2, true);
ballR.value = Math.max(10, Math.min(80, ballR._signal._value + dy * 0.1));
}
break;
case INPUT_TOUCH:
if (payload.length >= 6) {
const tx = view.getUint16(1, true), ty = view.getUint16(3, true);
const phase = payload[5];
if (phase === 0) handlePointer(tx, ty, 'down');
else handlePointer(tx, ty, 'move');
}
break;
case INPUT_TOUCH_END:
handlePointer(0, 0, 'up');
break;
case INPUT_GAMEPAD_AXIS:
if (payload.length >= 3) {
const axis = payload[0], val = view.getInt16(1, true) / 32768;
if (axis === 0) { targetX.value = 300 + val * 250; ballX.value = targetX._value; }
if (axis === 1) { targetY.value = 200 + val * 150; ballY.value = targetY._value; }
}
break;
case INPUT_GAMEPAD_BUTTON:
if (payload.length >= 3 && payload[1] === 1) {
ballR.value = 70; setTimeout(() => ballR.value = 25, 250);
}
break;
}
}
// ── Capture & Send ──
let streamingInterval = null;
function startStreaming() { if (!streamingInterval) streamingInterval = setInterval(captureAndSend, 1000 / 30); }
function stopStreaming() { if (streamingInterval) { clearInterval(streamingInterval); streamingInterval = null; } }
function captureAndSend() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const ts = Math.round(performance.now() - streamStart);
let msg;
switch (streamMode) {
case 'pixel':
msg = sendPixelFrame(ts, false);
break;
case 'delta':
msg = sendPixelFrame(ts, true);
break;
case 'signal':
msg = sendSignalFrame(ts);
break;
case 'neural':
msg = sendNeuralFrame(ts);
break;
}
if (msg) {
ws.send(msg.buffer);
seq++; framesSent++; bytesSent += msg.length; lastSecBytes += msg.length; lastSecFrames++;
visBars[visIdx % visBars.length].style.height = Math.min(25, Math.max(2, msg.length / 30000)) + 'px';
visIdx++;
}
// Feature 4: Send audio alongside
if (audioEnabled) {
const samples = synthesizeAudio();
if (samples) {
const audioMsg = encodeAudioFrame(samples, seq & 0xFFFF, ts);
ws.send(audioMsg.buffer);
bytesSent += audioMsg.length;
lastSecBytes += audioMsg.length;
}
}
}
// ── Mode: Raw Pixels ──
function sendPixelFrame(ts, useDelta) {
const imageData = ctx.getImageData(0, 0, W, H);
const pixels = imageData.data;
if (useDelta && prevFrame) {
// Feature 1: XOR Delta Compression
const delta = new Uint8Array(pixels.length);
let zeroCount = 0;
for (let i = 0; i < pixels.length; i++) {
delta[i] = pixels[i] ^ prevFrame[i];
if (delta[i] === 0) zeroCount++;
}
lastDeltaRatio = (zeroCount / pixels.length * 100);
if (zeroCount > pixels.length * 0.3) {
// Delta is worthwhile — compress by RLE-encoding the zero runs
const compressed = rleCompress(delta);
prevFrame = new Uint8Array(pixels);
const header = encodeHeader(FRAME_DELTA, FLAG_COMPRESSED, seq & 0xFFFF, ts, W, H, compressed.length);
const msg = new Uint8Array(HEADER_SIZE + compressed.length);
msg.set(header, 0); msg.set(compressed, HEADER_SIZE);
return msg;
}
}
// Send full keyframe
prevFrame = new Uint8Array(pixels);
const header = encodeHeader(FRAME_PIXELS, FLAG_KEYFRAME, seq & 0xFFFF, ts, W, H, pixels.length);
const msg = new Uint8Array(HEADER_SIZE + pixels.length);
msg.set(header, 0); msg.set(pixels, HEADER_SIZE);
return msg;
}
// RLE compression matching Rust codec: 0x00 + 2-byte LE count for zero runs
function rleCompress(data) {
const out = [];
let i = 0;
while (i < data.length) {
if (data[i] === 0) {
let run = 0;
while (i < data.length && data[i] === 0) { run++; i++; }
// Emit run in chunks of max 65535
while (run > 0) {
const chunk = Math.min(run, 65535);
out.push(0, chunk & 0xFF, (chunk >> 8) & 0xFF);
run -= chunk;
}
} else {
out.push(data[i]); i++;
}
}
return new Uint8Array(out);
}
// ── Feature 3: Signal Diff Mode ──
function sendSignalFrame(ts) {
const state = {
bx: Math.round(ballX._signal._value * 10) / 10,
by: Math.round(ballY._signal._value * 10) / 10,
br: Math.round(ballR._signal._value * 10) / 10,
bvx: Math.round(ballX._velocity * 10) / 10,
bvy: Math.round(ballY._velocity * 10) / 10,
tx: Math.round(targetX._value * 10) / 10,
ty: Math.round(targetY._value * 10) / 10,
drag: dragging ? 1 : 0,
};
const json = JSON.stringify(state);
const payload = new TextEncoder().encode(json);
// Send full sync (with FLAG_KEYFRAME) or diff
const isSync = !prevSignalState || (framesSent % 150 === 0); // sync every ~5s at 30fps
const frameType = isSync ? FRAME_SIGNAL_SYNC : FRAME_SIGNAL_DIFF;
const flags = isSync ? FLAG_KEYFRAME : 0;
prevSignalState = state;
const header = encodeHeader(frameType, flags, seq & 0xFFFF, ts, W, H, payload.length);
const msg = new Uint8Array(HEADER_SIZE + payload.length);
msg.set(header, 0); msg.set(payload, HEADER_SIZE);
return msg;
}
// ── Feature 5: Neural Renderer Mode ──
function sendNeuralFrame(ts) {
const imgData = neuralRender();
const pixels = imgData.data;
const header = encodeHeader(FRAME_NEURAL, FLAG_KEYFRAME, seq & 0xFFFF, ts, W, H, pixels.length);
const msg = new Uint8Array(HEADER_SIZE + pixels.length);
msg.set(header, 0); msg.set(pixels, HEADER_SIZE);
return msg;
}
// ── Stats ──
setInterval(() => {
const now = performance.now(), elapsed = (now - lastSecTime) / 1000;
document.getElementById('statFps').textContent = Math.round(lastSecFrames / elapsed);
document.getElementById('statMode').textContent = streamMode;
const avgSize = lastSecFrames > 0 ? lastSecBytes / lastSecFrames : 0;
if (avgSize > 1024 * 1024) document.getElementById('statFrameSize').textContent = (avgSize / 1024 / 1024).toFixed(1) + 'MB';
else if (avgSize > 1024) document.getElementById('statFrameSize').textContent = (avgSize / 1024).toFixed(0) + 'KB';
else document.getElementById('statFrameSize').textContent = Math.round(avgSize) + 'B';
const bw = lastSecBytes / 1024 / 1024 / elapsed;
document.getElementById('statBandwidth').textContent = bw < 1 ? (bw * 1024).toFixed(0) + 'KB/s' : bw.toFixed(1) + 'MB/s';
document.getElementById('statDelta').textContent = streamMode === 'delta' ? lastDeltaRatio.toFixed(0) + '%' : '—';
document.getElementById('statFrames').textContent = framesSent;
document.getElementById('statInputs').textContent = inputsRecv;
document.getElementById('statAudio').textContent = audioEnabled ? 'on' : 'off';
document.getElementById('statReceivers').textContent = receiverCount;
lastSecBytes = 0; lastSecFrames = 0; lastSecTime = now;
}, 1000);
connect();
</script>
</body>
</html>