221 lines
8.2 KiB
JavaScript
221 lines
8.2 KiB
JavaScript
// /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
|
|
};
|
|
})(); |