Files
appRobotVideoControls/public/videoService.js

221 lines
8.2 KiB
JavaScript
Executable File

// /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 = `<span style="color:${color}">${state.toUpperCase()}</span>${extra ? ' — ' + extra : ''}`;
}
async function blobToBitmap(blob) {
// createImageBitmap is widely supported; fallback to <img> 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: <a href="${j.url}" target="_blank" rel="noopener">${j.url}</a>`;
}).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: <a href="${msg.url}" target="_blank" rel="noopener">${msg.url}</a></br></br> ` +
` Recognized: <a href="${msg.urlApp}" target="_blank" rel="noopener">${msg.urlApp}</a></br> ` +
` Overlay: <a href="${overlayPNG}" target="_blank" rel="noopener">PNG Overlay</a>`;
// 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
};
})();