// /public/videoService.js // Client-side helper for consuming binary JPEG frames over WebSocket, // decoding with createImageBitmap, and drawing to a canvas with RAF. // For /ws/video0, supports sending control messages as JSON text. (function () { class FrameRenderer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d', { alpha: false, desynchronized: true, willReadFrequently: false }); this.queue = []; this.maxQueue = 2; // keep at most 2 pending frames to limit latency this._rafId = null; this._running = false; this._lastDraw = 0; } enqueue(bitmap) { if (this.queue.length >= this.maxQueue) { // drop older frame to keep latency low const old = this.queue.shift(); if (old && 'close' in old) try { old.close(); } catch {} } this.queue.push(bitmap); if (!this._running) this.start(); } start() { if (this._running) return; this._running = true; const loop = (ts) => { if (!this._running) return; const frame = this.queue.shift(); if (frame) { const w = this.canvas.width, h = this.canvas.height; this.ctx.drawImage(frame, 0, 0, w, h); if ('close' in frame) try { frame.close(); } catch {} this._lastDraw = ts; } this._rafId = requestAnimationFrame(loop); }; this._rafId = requestAnimationFrame(loop); } stop() { this._running = false; if (this._rafId) cancelAnimationFrame(this._rafId); this._rafId = null; // cleanup queued frames while (this.queue.length) { const f = this.queue.shift(); if (f && 'close' in f) try { f.close(); } catch {} } // Clear canvas this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); } } function prettyStatus(el, state, extra = '') { const color = { connecting: '#9eb8ff', open: '#8cffbf', closed: '#ff9e9e', error: '#ffb167' }[state] || '#e7eaf6'; el.innerHTML = `${state.toUpperCase()}${extra ? ' — ' + extra : ''}`; } async function blobToBitmap(blob) { // createImageBitmap is widely supported; fallback to if needed return createImageBitmap(blob); } function parseWH(value) { if (!value) return null; const [w, h] = String(value).split('x').map(Number); if (!w || !h) return null; return { width: w, height: h }; } function attachStream({ url, canvas, statusEl, control }) { const renderer = new FrameRenderer(canvas); let ws; let reconnectDelay = 1000; let closedOnPurpose = false; function connect() { prettyStatus(statusEl, 'connecting', url); ws = new WebSocket(url); ws.binaryType = 'blob'; ws.onopen = () => { reconnectDelay = 1000; prettyStatus(statusEl, 'open', 'streaming…'); // On connection, fetch the latest snapshot metadata so the UI can show // the freshest overlay and CSV (prevents showing an old image cached in index.html). if (control?.snapshotOutEl) { fetch('/snapshots/latest', { cache: 'no-store' }) .then(r => r.ok ? r.json() : Promise.reject(new Error('no snapshot'))) .then((j) => { if (!j?.ok) return; const overlayPNG = (j.overlay ? `${j.overlay}?_t=${Date.now()}` : null); const overlayCSV = (j.overlayCSV ? `${j.overlayCSV}?_t=${Date.now()}` : null); if (overlayPNG) { document.getElementById('overlayImg').src = overlayPNG; } if (window.readCSV && overlayCSV) { window.readCSV.renderCSV('csvTable', overlayCSV); } // Show links to the latest snapshot in the snapshot output box (non-intrusive) control.snapshotOutEl.innerHTML = `Latest snapshot: ${j.url}`; }).catch(() => { // ignore — no latest snapshot yet }); } }; ws.onmessage = async (evt) => { if (typeof evt.data === 'string') { // Control/meta message try { const msg = JSON.parse(evt.data); if (msg.type === 'error') { console.warn('Server error:', msg.error); prettyStatus(statusEl, 'error', msg.error); } if (msg.type === 'snapshot' && control?.snapshotOutEl) { if (msg.ok && msg.url && msg.urlApp) { // Use overlay URL and CSV provided by the server (fallback to expected names) const overlayPNG = msg.overlay || msg.urlApp.replace('_annotated.jpg','_two_cam_overlay.png'); // Prefer server-provided overlayCSV. If not present, derive from the original jpg name const overlayCSV = msg.overlayCSV || msg.url.replace('.jpg','_two_cam.csv'); control.snapshotOutEl.innerHTML = `Snapshot: ${msg.url}

` + ` Recognized: ${msg.urlApp}
` + ` Overlay: PNG Overlay`; // Update overlay immediately — server now ensures files are ready before responding document.getElementById('overlayImg').src = overlayPNG; // Render CSV values into the csvTable container (if available) if (window.readCSV) { window.readCSV.renderCSV('csvTable', overlayCSV); } } else { control.snapshotOutEl.textContent = 'Snapshot failed'; } } } catch { // ignore non-JSON text frames } return; } // Binary JPEG frame const blob = evt.data; // Blob of image/jpeg try { const bmp = await blobToBitmap(blob); renderer.enqueue(bmp); } catch (err) { console.warn('Bitmap decode failed:', err); } }; ws.onerror = () => { prettyStatus(statusEl, 'error', 'network error'); }; ws.onclose = () => { renderer.stop(); prettyStatus(statusEl, 'closed', closedOnPurpose ? 'by client' : 'retrying…'); if (!closedOnPurpose) { setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, 10000); } }; } connect(); // Attach controls if provided (video0) if (control) { const { resSelect, fpsSelect, qSelect, applyBtn, snapshotBtn, startBtn, stopBtn, snapshotOutEl } = control; applyBtn?.addEventListener('click', () => { if (!ws || ws.readyState !== WebSocket.OPEN) return; const wh = parseWH(resSelect?.value); const fps = Number(fpsSelect?.value || 15); const q = Number(qSelect?.value || 5); ws.send(JSON.stringify({ type: 'control', action: 'setParams', params: { ...(wh || {}), fps, quality: q } })); }); snapshotBtn?.addEventListener('click', () => { if (!ws || ws.readyState !== WebSocket.OPEN) return; snapshotOutEl.textContent = 'Taking snapshot…'; ws.send(JSON.stringify({ type: 'control', action: 'snapshot' })); }); startBtn?.addEventListener('click', () => { if (!ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: 'control', action: 'start' })); }); stopBtn?.addEventListener('click', () => { if (!ws || ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify({ type: 'control', action: 'stop' })); }); } return { close: () => { closedOnPurpose = true; ws?.close(); renderer.stop(); } }; } window.VideoService = { attachStream }; })();