Committed and Pushed by ButtonClick

This commit is contained in:
ChK
2025-12-22 15:32:40 +01:00
parent 6331504d25
commit 0fd1e5d747
33 changed files with 4205 additions and 1 deletions

110
public/GamePad.js Executable file
View File

@@ -0,0 +1,110 @@
var isRunning = false;
var gamePadId = 0;
var gamepad = {};
let lastCheckTime = 0;
function checkGamePad() {
if(isRunning == false){return;}
const stepSize = "0.01";
const stepSizeXYZ = "0.5"; // 3 ist auch ok
const stepSizeE = "0.01";
var gp = navigator.getGamepads()[gamePadId]
var buttons = gp.buttons
var xyzSpeed = 10; // 100 geht auch
var psi = gp.axes[0];
var z = gp.axes[1];
var x = gp.axes[2];
var y = gp.axes[3];
// Dreieck zum Dreieck-Setzen
if (buttons[3].pressed) {
socketDriver.send(`G90 G1 X0 Y300 Z0 A${Math.PI/2} B${-1.0*Math.PI/2} C0 F${xyzSpeed}`);
}
if (buttons[4].pressed) {
//console.log("x=" + robot.x + " y=" + robot.y + " z=" + robot.z);
}
// X Button setzt eine Marke
if(buttons[0].pressed && (Date.now() - lastCheckTime > 500)){
lastCheckTime = Date.now()
console.log('FPoint!');
socketDriver.send('FPoint');
socketDriver.send('FShow');
}
// L1 und R1 Button to forward-backward in Point-List
if(gp.buttons[4].pressed && (Date.now() - lastCheckTime > 500)){
lastCheckTime = Date.now()
socketDriver.send('FMinus');
socketDriver.send('FShow');
}
if(gp.buttons[5].pressed && (Date.now() - lastCheckTime > 500)){
lastCheckTime = Date.now()
socketDriver.send('FPlus');
socketDriver.send('FShow');
}
if (x < -0.2) { socketDriver.send(`G91 G1 X+${stepSizeXYZ} F${xyzSpeed}`);}
if (x > 0.2) { socketDriver.send(`G91 G1 X-${stepSizeXYZ} F${xyzSpeed}`);}
if (y < -0.2) { socketDriver.send(`G91 G1 Y${stepSizeXYZ} F${xyzSpeed}`); }
if (y > 0.2) { socketDriver.send(`G91 G1 Y-${stepSizeXYZ} F${xyzSpeed}`);}
if (z < -0.2) { socketDriver.send(`G91 G1 Z${stepSizeXYZ} F${xyzSpeed}`); }
if (z > 0.2) { socketDriver.send(`G91 G1 Z-${stepSizeXYZ} F${xyzSpeed}`); }
// Greif-Richtung
// LeftRight
if(buttons[14].pressed){ socketDriver.send(`G91 G1 A${stepSize} F${xyzSpeed}`);}
if(buttons[15].pressed){ socketDriver.send(`G91 G1 A-${stepSize} F${xyzSpeed}`);}
// Up - Down
if(buttons[12].pressed){ socketDriver.send(`G91 G1 B${stepSize} F${xyzSpeed}`);}
if(buttons[13].pressed){ socketDriver.send(`G91 G1 B-${stepSize} F${xyzSpeed}`);}
// Drehung
if (psi < -0.2) { socketDriver.send(`G91 G1 C${stepSize} F${xyzSpeed}`); }
if (psi > 0.2) { socketDriver.send(`G91 G1 C-${stepSize} F${xyzSpeed}`); }
// Trigger-Buttons für Öffnen und Schliessen
if(buttons[6].pressed){socketDriver.send(`G91 G1 E-${stepSizeE} F${xyzSpeed}`);}
if(buttons[7].pressed){socketDriver.send(`G91 G1 E${stepSizeE} F${xyzSpeed}`);}
if (isRunning) { setTimeout(checkGamePad, 15);}
}
function gamepadHandler(event, connecting) {
gamepad = event.gamepad;
if (typeof gamepad === `undefined`) {
isRunning = false;
gamePadId = 0;
console.warn("GamePad kann nicht gefunden werden");
return;
}
if (connecting) {
console.log("GamePad " + event.gamepad.index + " connected");
gamePadId = gamepad.index;
isRunning = true;
setTimeout(checkGamePad, 20);
} else {
console.log("GamePad " +gamePadId +" disconnected");
isRunning = false;
gamePadId = 0;
}
}
window.addEventListener("gamepadconnected", function (e) { gamepadHandler(e, true); }, false);
window.addEventListener("gamepaddisconnected", function (e) { gamepadHandler(e, false); }, false);
document.addEventListener("touchstart", e => { console.log("TouchStart"); })

64
public/KeyboardInput.js Executable file
View File

@@ -0,0 +1,64 @@
document.onkeydown = checkKey;
function checkKey(e) {
e = e || window.event;
if(e.key == 'a' || e.key == 'A')
{
console.log("back to A position");
socketDriver.send(`G90 G1 X0 Y300 Z0 A${Math.PI/2} B-${Math.PI/2} C0 F100`);
}
// Hand-Winkel (Eulerwinkel)
else if(e.key == 'i' || e.key == 'I'){
socketDriver.send('G91 G1 B+0.1 F100');
}
else if(e.key == 'k' || e.key == 'K'){
socketDriver.send('G91 G1 B-0.1 F100');
}
else if(e.key == 'l' || e.key == 'L'){
socketDriver.send('G91 G1 A+0.1 F100');
}
else if(e.key == 'j' || e.key == 'J'){
socketDriver.send('G91 G1 A-0.1 F100');
}
else if(e.key == 'o' || e.key == 'O'){
socketDriver.send('G91 G1 C+0.1 F100');
}
else if(e.key == 'u' || e.key == 'U'){
socketDriver.send('G91 G1 C-0.1 F100');
}
// XYZ Koordinaten
else if(e.key == 'e' || e.key == 'E'){
socketDriver.send('G91 G1 Z+5 F100');
}
else if(e.key == 'd' || e.key == 'D'){
socketDriver.send('G91 G1 Z-5 F100');
}
else if(e.key == 's' || e.key == 'S'){
socketDriver.send('G91 G1 X5 F100');
}
else if(e.key == 'f' || e.key == 'S'){
socketDriver.send('G91 G1 X-5 F100');
}
else if(e.key == 'r' || e.key == 'R'){
socketDriver.send('G91 G1 Y5 F100');
}
else if(e.key == 'w' || e.key == 'W'){
socketDriver.send('G91 G1 Y-5 F100');
}
// File & Log-Operations
else if(e.key == ' '){
console.log('FPoint!')
socketDriver.send('FPoint');
}
else if(e.key == 'b'){
socketDriver.send('FMinus');
}
else if(e.key == 'n'){
socketDriver.send('FPlus');
}
}

25
public/WebSocket.js Executable file
View File

@@ -0,0 +1,25 @@
const protocolS = location.protocol === "https:" ? "wss://" : "ws://";
const robotURL = protocolS + location.host + "/ws/robot";
console.log("socketDriver try to connect to "+ robotURL);
const socketDriver = new WebSocket(robotURL);
socketDriver.onopen = () => { console.log("socketDriver WebSocket connected"); };
socketDriver.onmessage = (event) => {
if(event.data.toString().includes("position")){
console.log("Position: " + event.data);
}
else if(event.data.toString().includes("XYZ__FShow__XYZ")){
const content = event.data.toString().split("XYZ__FShow__XYZ")[1];
document.querySelectorAll("textarea#GCodeWindow.editor-look")[0].value = content;
}
else{
console.log('DATA SinceStartup: ' + (Date.now() - startTime).toString() +': ', event.data);
}
}
socketDriver.onclose = () => {console.log("socketDriver WebSocket is closing");};
socketDriver.onerror = (err) => { console.error("socketDriver socket error:", err); };

72
public/app.js Executable file
View File

@@ -0,0 +1,72 @@
// /public/app.js
// Small bootstrap moved out of index.html to satisfy CSP (`script-src 'self'`).
(function () {
const isSecure = location.protocol === 'https:';
const wsProto = isSecure ? 'wss' : 'ws';
const base = `${wsProto}://${location.host}`;
// Camera 0 with control channel
window.VideoService.attachStream({
url: `${base}/ws/video0`,
canvas: document.getElementById('canvas0'),
statusEl: document.getElementById('status0'),
control: {
resSelect: document.getElementById('res0'),
fpsSelect: document.getElementById('fps0'),
qSelect: document.getElementById('q0'),
applyBtn: document.getElementById('apply0'),
snapshotBtn: document.getElementById('snap0'),
startBtn: document.getElementById('start0'),
stopBtn: document.getElementById('stop0'),
snapshotOutEl: document.getElementById('snapshotLink'),
}
});
// Camera 1 (no control)
window.VideoService.attachStream({
url: `${base}/ws/video1`,
canvas: document.getElementById('canvas1'),
statusEl: document.getElementById('status1'),
});
// Run an automatic snapshot shortly after the page loads so the client
// receives the freshest annotated image/CSV on startup. This uses a
// synthetic click on the snapshot button; the handler will no-op if the
// WebSocket isn't open yet.
setTimeout(() => {
const snapBtn = document.getElementById('snap0');
if (snapBtn) snapBtn.click();
}, 200);
// Attach handlers for the Point/Up/Down buttons (no inline JS, CSP-safe)
const btnPoint = document.getElementById('btnPoint');
const btnDown = document.getElementById('btnDown');
const btnUp = document.getElementById('btnUp');
if (btnPoint) btnPoint.addEventListener('click', () => {
console.log('FPoint!');
try { socketDriver.send('FPoint'); socketDriver.send('FShow'); } catch (e) { console.error(e); }
});
if (btnDown) btnDown.addEventListener('click', () => {
console.log('FPlus');
try { socketDriver.send('FPlus'); socketDriver.send('FShow'); } catch (e) { console.error(e); }
});
if (btnUp) btnUp.addEventListener('click', () => {
console.log('FMinus');
try { socketDriver.send('FMinus'); socketDriver.send('FShow'); } catch (e) { console.error(e); }
});
// Other buttons that were previously using inline onclicks.
const btnInfo = document.getElementById('b_M114');
const btnNull = document.getElementById('b_G28');
const btnListFile = document.getElementById('btnListFile');
const btnSendGCode = document.getElementById('btnSendGCode');
if (btnInfo) btnInfo.addEventListener('click', () => { try { socketDriver.send('M114'); } catch (e) { console.error(e); } });
if (btnNull) btnNull.addEventListener('click', () => { try { socketDriver.send('G28'); } catch (e) { console.error(e); } });
if (btnListFile) btnListFile.addEventListener('click', () => { try { socketDriver.send('FShow'); } catch (e) { console.error(e); } });
if (btnSendGCode) btnSendGCode.addEventListener('click', () => {
try {
const input = document.getElementById('GKarth');
if (input && input.value) socketDriver.send(input.value);
} catch (e) { console.error(e); }
});
})();

23
public/buttonCmd.js Executable file
View File

@@ -0,0 +1,23 @@
document.getElementById("b_uparrow").addEventListener('click',() => { socketDriver.send(`G91 G1 Z10 F100`);});
document.getElementById("b_downarw").addEventListener('click',() => { socketDriver.send(`G90 G1 Z-10 F100`);});
document.getElementById("b_right").addEventListener('click',() => { socketDriver.send(`G90 G1 X10 F100`);});
document.getElementById("b_left").addEventListener('click',() => { socketDriver.send(`G90 G1 X-10 F100`);});
document.getElementById("b_forward").addEventListener('click',() => { socketDriver.send(`G90 G1 Y10 F100`);});
document.getElementById("b_backward").addEventListener('click',() => { socketDriver.send(`G90 G1 Y-10 F100`);});
document.getElementById("b_aPlus").addEventListener('click',() => { socketDriver.send(`G91 G1 A0.10 F100`);});
document.getElementById("b_aMinus").addEventListener('click',() => { socketDriver.send(`G91 G1 A-0.10 F100`);});
document.getElementById("b_bPlus").addEventListener('click',() => { socketDriver.send(`G91 G1 B0.10 F100`);});
document.getElementById("b_bMinus").addEventListener('click',() => { socketDriver.send(`G91 G1 B-0.10 F100`);});
document.getElementById("b_cPlus").addEventListener('click',() => { socketDriver.send(`G91 G1 C0.10 F100`);});
document.getElementById("b_cMinus").addEventListener('click',() => { socketDriver.send(`G91 G1 C-0.10 F100`);});
document.getElementById("b_ePlus").addEventListener('click',() => { socketDriver.send(`G91 G1 E0.10 F100`);});
document.getElementById("b_eMinus").addEventListener('click',() => { socketDriver.send(`G91 G1 E-0.10 F100`);});
document.getElementById("b_default").addEventListener('click',() => { socketDriver.send(`G90 G1 X0 Y300 Z0 A${Math.PI/2} B-${Math.PI/2} C0 F100`);});
document.getElementById("b_G28").addEventListener('click',() => { socketDriver.send(`G28`);});
document.getElementById("b_M114").addEventListener('click',() => { socketDriver.send(`M114`);});

108
public/index.html Executable file
View File

@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Dual Camera Stream (HTTPS + WSS)</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<script src="WebSocket.js" defer></script>
<script src="buttonCmd.js" defer></script>
<script src="GamePad.js" defer></script>
<script src="KeyboardInput.js" defer></script>
<link rel="stylesheet" href="indexA.css">
</head>
<body>
<h1>Robot Control</h1>
<div class="grid">
<!-- Camera 0 -->
<section class="panel">
<h2>Camera 0</h2>
<div class="video-wrap">
<canvas id="canvas0" width="1280" height="720"></canvas>
<img id="overlayImg"
src="/snapshots/snapshot_video0_1765356085372_two_cam_overlay.png">
</div>
<div id="status0" class="status">Connecting…</div>
</section>
<!-- Camera 1 -->
<section class="panel">
<h2>Camera 1</h2>
<div class="video-wrap">
<canvas id="canvas1" width="1280" height="720"></canvas>
</div>
<div id="status1" class="status">Connecting…</div>
</section>
<section class="panel">
<div class="controls">
<div style="display:flex; gap:0.5rem; align-items:end;">
<button id="snap0">Snapshot</button>
</div>
</div>
<div id="snapshotLink" class="status"></div>
<div id="csvTable" class="status"></div>
</section>
<section class="panel">
<div id="divButtons">
<h3>Controls</h3>
<button id="b_uparrow">Up</button>
<button id="b_downarw">Down</button>
</br/>
<button id="b_right">Right</button>
<button id="b_left">Left</button>
<br/>
<button id="b_forward">Forward</button>
<button id="b_backward">Backward</button>
<br/>
<button id="b_M114">Info</button>
<button id="b_G28">Null</button>
<button id="b_default">△ Default</button>
<br/>
<br/>
<button id="b_aPlus" >a+</button>
<button id="b_aMinus">a-</button>
<br/>
<button id="b_bPlus" >B+</button>
<button id="b_bMinus">B-</button>
<br/>
<button id="b_cPlus" >C+</button>
<button id="b_cMinus">C-</button>
<br/>
<button id="b_ePlus" >E+</button>
<button id="b_eMinus">E-</button>
</div>
<div id="divGCodeWindow">
<div id="GCode">
<form onsubmit="return false;"><input id="GKarth"/><button id="btnSendGCode" type="button">Send GCode</button></form>
<br/>
</div>
<button id="btnListFile" type="button">ListFile</button>
<br/>
<textarea id="GCodeWindow" class="editor-look" readonly>
</textarea>
<br/>
<button id="btnPoint">🗙 Point</button>
<button id="btnDown"></button>
<button id="btnUp"></button>
</div>
</section>
</div>
<!-- External scripts only (CSP-safe, no inline JS) -->
<script src="/readCSV.js" defer></script>
<script src="/videoService.js" defer></script>
<script src="/app.js" defer></script>
</body>
</html>

203
public/indexA.css Executable file
View File

@@ -0,0 +1,203 @@
:root { color-scheme: dark light; }
/* Overlay opacity is configurable here. Set to 1 to let PNG alpha be authoritative */
:root {
--overlay-opacity: 1; /* default overlay alpha applied client-side */
}
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
margin: 0;
padding: 1rem;
background: #0b1020;
color: #e7eaf6;
}
h1, h2 {
margin: 0.5rem 0 0.25rem;
}
.grid {
display: grid;
gap: 1rem;
/*
grid-auto-flow: column;
grid-auto-columns: 1280px;
justify-content: start;
*/
grid-template-columns: repeat(auto-fit, minmax(1280px, 1fr));
align-items: start;
}
.panel {
background: #141b34;
border: 1px solid #242c4f;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 10px 24px rgba(0,0,0,0.25);
}
.video-wrap {
position: relative;
background: #000;
border-radius: 8px;
overflow: hidden;
/* Keep a 16:9 box that doesn't stretch beyond the source resolution */
width: 100%;
max-width: 1280px;
aspect-ratio: 16 / 9;
margin: 0 auto;
}
/* Ensure canvas and overlay are sized identically and keep the same aspect ratio
so overlays align correctly when the window is resized. */
.video-wrap canvas,
.video-wrap img,
#overlayImg {
position: absolute;
inset: 0; /* top:0; right:0; bottom:0; left:0 */
width: 100%;
height: 100%;
/* Use `contain` so both layers scale uniformly and show letterboxing instead
of stretching or cropping when the container size differs from the image. */
object-fit: contain;
background: #000;
}
.video-wrap canvas {
z-index: 1;
}
.video-wrap img,
#overlayImg {
z-index: 2;
pointer-events: none; /* don't block interactions with the canvas */
opacity: var(--overlay-opacity);
}
.status {
font-size: 0.9rem;
opacity: 0.85;
margin: 0.5rem 0 0;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
margin-top: 0.75rem;
}
.controls label {
display: grid;
gap: 0.25rem;
font-size: 0.9rem;
}
select, input, button {
padding: 0.5rem 0.6rem;
border-radius: 8px;
border: 1px solid #2d3766;
background: #0d1530;
color: #e7eaf6;
}
button.primary {
background: #355dff;
border-color: #355dff;
}
a {
color: #9eb8ff;
}
footer {
margin-top: 1.25rem;
opacity: 0.7;
font-size: 0.9rem;
}
.muted {
opacity: 0.8;
}
/* Ensure overlay uses contain and respects alpha channel from the PNG
(the PNG's transparency is the authoritative source of which pixels
are transparent; CSS opacity only adjusts global alpha). */
#overlayImg {
position: absolute;
inset: 0; /* shorthand for top:0; right:0; bottom:0; left:0; */
width: 100%;
height: 100%;
pointer-events: none;
opacity: var(--overlay-opacity);
object-fit: contain; /* preserve aspect ratio and don't distort */
background: transparent;
}
/* CSV table styles */
.csv-table {
width: 440px; /* 4 columns * 200px each */
border-collapse: collapse;
margin-top: 0.5rem;
table-layout: fixed; /* use fixed layout so column widths are respected */
}
.csv-table th, .csv-table td {
border: 1px solid rgba(255,255,255,0.06);
padding: 0.35rem 0.5rem;
text-align: center;
font-size: 0.9rem;
width: 110px; /* fixed column width */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.csv-table th {
background: rgba(255,255,255,0.02);
font-weight: 600;
}
.csv-table tr:nth-child(even) td {
background: rgba(255,255,255,0.01);
}
/* Right-align CSV numeric data and use tabular digits for stable column alignment */
.csv-table td { text-align: right; font-variant-numeric: tabular-nums; }
#GCodeWindow {
width: 100%;
height: 200px;
}
/* Layout: place the buttons and GCode window side-by-side in their panel.
- `#divButtons` gets a fixed ~300px width
- `#divGCodeWindow` uses the remaining width
- On small screens they stack vertically for usability */
/* Use floats for a predictable two-column layout inside the panel. */
#divButtons {
float: left;
width: 300px;
box-sizing: border-box;
margin-right: 1rem;
}
#divGCodeWindow {
display: block;
margin-left: 320px; /* 300px left column + 1rem gap */
box-sizing: border-box;
}
/* Clear floats inside panels so subsequent content flows correctly */
.panel::after {
content: "";
display: table;
clear: both;
}
@media (max-width: 800px) {
#divButtons, #divGCodeWindow {
display: block;
float: none;
width: 100%;
margin-left: 0;
margin-right: 0;
}
}

76
public/readCSV.js Executable file
View File

@@ -0,0 +1,76 @@
// public/readCSV.js
// Fetch a CSV file and render a small table into the given container.
// Usage: window.readCSV.renderCSV(containerOrId, csvUrl)
(function () {
async function renderCSV(container, csvUrl) {
console.log("readCSV should start");
console.log('readCSV.renderCSV', container, csvUrl);
const el = (typeof container === 'string') ? document.getElementById(container) : container;
if (!el) return;
if (!csvUrl) {
el.innerHTML = '<em>No CSV URL provided</em>';
return;
}
el.innerHTML = 'Loading CSV…';
try {
const res = await fetch(csvUrl, { cache: 'no-store' });
if (!res.ok) {
el.innerHTML = `<em>CSV not found (HTTP ${res.status})</em>`;
return;
}
const text = await res.text();
const lines = text.trim().split(/\r?\n/).filter(Boolean);
if (!lines.length) {
el.innerHTML = '<em>CSV is empty</em>';
return;
}
const headers = lines[0].split(',').map(h => h.trim());
// Look for id,x,y,z columns (case-insensitive)
const lower = headers.map(h => h.toLowerCase());
const ids = {
id: lower.indexOf('id'),
x: lower.indexOf('x_mm'),
y: lower.indexOf('y_mm'),
z: lower.indexOf('z_mm')
};
// If any of these are missing, show a helpful message
if (ids.id === -1 || ids.x === -1 || ids.y === -1 || ids.z === -1) {
el.innerHTML = `<em>CSV does not contain required columns: id, x, y, z</em> ${csvUrl}`;
return;
}
// Build table
const table = document.createElement('table');
table.className = 'csv-table';
const thead = table.createTHead();
const thr = thead.insertRow();
['id', 'x [mm]', 'y [mm]', 'z [mm]'].forEach(h => {
const th = document.createElement('th');
th.textContent = h;
thr.appendChild(th);
});
const tbody = table.createTBody();
for (let i = 1; i < lines.length; i++) {
const row = lines[i].split(',').map(c => c.trim());
// Skip if row doesn't have enough columns
if (row.length < headers.length) continue;
const tr = tbody.insertRow();
['id', 'x', 'y', 'z'].forEach(k => {
const td = tr.insertCell();
td.textContent = row[ids[k]] ?? '';
});
}
el.innerHTML = ''; // clear
el.appendChild(table);
} catch (err) {
console.error('readCSV error', err);
el.innerHTML = `<em>Error loading CSV</em>`;
}
}
window.readCSV = { renderCSV };
})();

221
public/videoService.js Executable file
View File

@@ -0,0 +1,221 @@
// /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
};
})();