Lightningbeam/src/frame-receiver.js

103 lines
3.0 KiB
JavaScript

// WebSocket frame receiver for zero-copy video playback
// Uses ArrayBuffer views to avoid copying data
export class FrameReceiver {
constructor() {
this.ws = null;
this.port = null;
this.connected = false;
this.frameCallbacks = new Map(); // pool_index -> callback(imageData, timestamp)
}
async connect() {
// Get WebSocket port from Tauri
const { invoke } = window.__TAURI__.core;
this.port = await invoke('get_frame_streamer_port');
const wsUrl = `ws://127.0.0.1:${this.port}`;
console.log(`[FrameReceiver] Connecting to ${wsUrl}`);
return new Promise((resolve, reject) => {
this.ws = new WebSocket(wsUrl);
this.ws.binaryType = 'arraybuffer'; // Important: receive as ArrayBuffer for zero-copy
this.ws.onopen = () => {
console.log('[FrameReceiver] Connected');
this.connected = true;
resolve();
};
this.ws.onerror = (error) => {
console.error('[FrameReceiver] WebSocket error:', error);
reject(error);
};
this.ws.onclose = () => {
console.log('[FrameReceiver] Disconnected');
this.connected = false;
};
this.ws.onmessage = (event) => {
this.handleFrame(event.data);
};
});
}
handleFrame(arrayBuffer) {
// Frame format: [pool_index: u32][timestamp_ms: u32][width: u32][height: u32][rgba_data...]
// Create DataView for reading header (zero-copy view into buffer)
const view = new DataView(arrayBuffer);
const poolIndex = view.getUint32(0, true); // little-endian
const timestampMs = view.getUint32(4, true);
const width = view.getUint32(8, true);
const height = view.getUint32(12, true);
// Get callback for this pool
const callback = this.frameCallbacks.get(poolIndex);
if (!callback) {
// No subscriber for this pool
return;
}
// Create zero-copy view of RGBA data (starts at byte 16)
// IMPORTANT: Uint8ClampedArray is required for ImageData
// Specify exact length to avoid stride issues
const dataLength = width * height * 4;
const rgbaData = new Uint8ClampedArray(arrayBuffer, 16, dataLength);
// Create ImageData directly from the view (zero-copy!)
const imageData = new ImageData(rgbaData, width, height);
// Call subscriber with frame data
const timestamp = timestampMs / 1000.0;
callback(imageData, timestamp);
}
// Subscribe to frames for a specific video pool
subscribe(poolIndex, callback) {
console.log(`[FrameReceiver] Subscribing to pool ${poolIndex}`);
this.frameCallbacks.set(poolIndex, callback);
}
// Unsubscribe from a video pool
unsubscribe(poolIndex) {
console.log(`[FrameReceiver] Unsubscribing from pool ${poolIndex}`);
this.frameCallbacks.delete(poolIndex);
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.connected = false;
this.frameCallbacks.clear();
}
}
// Global singleton instance
export const frameReceiver = new FrameReceiver();